diff --git a/.github/actions/setup-zig/action.yml b/.github/actions/setup-zig/action.yml index 0673078..11d2c5d 100644 --- a/.github/actions/setup-zig/action.yml +++ b/.github/actions/setup-zig/action.yml @@ -9,7 +9,7 @@ runs: using: 'composite' steps: - name: Cache Zig - uses: actions/cache@v4 + uses: actions/cache@v5 id: cache-zig with: path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c86f429..fddec90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,9 @@ jobs: name: fmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -27,9 +27,9 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -43,9 +43,9 @@ jobs: name: type check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -59,11 +59,11 @@ jobs: name: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -84,11 +84,11 @@ jobs: name: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -103,8 +103,8 @@ jobs: run: | SIZE=$(stat -c%s ghostty-vt.wasm) echo "WASM size: $SIZE bytes" - if [ "$SIZE" -gt 524288 ]; then - echo "❌ Error: WASM exceeds 512 KB limit" + if [ "$SIZE" -gt 786432 ]; then + echo "❌ Error: WASM exceeds 768 KB limit" exit 1 fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 07a82d4..df1d283 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,14 +23,14 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'push' }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.ref }} fetch-depth: 0 # Required for git describe to find tags submodules: recursive - name: Setup Bun - uses: oven-sh/setup-bun@v1 + uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -61,9 +61,9 @@ jobs: run: bun run build - name: Setup Node.js for npm - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' registry-url: 'https://registry.npmjs.org' # Ensure npm 11.5.1 or later for trusted publishing @@ -159,15 +159,15 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'push' }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.ref }} fetch-depth: 0 - name: Setup Node.js for npm - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' registry-url: 'https://registry.npmjs.org' - run: npm install -g npm@latest diff --git a/bun.lock b/bun.lock index 557c739..20a486d 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "@cmux/ghostty-terminal", + "dependencies": { + "fast-png": "^7.0.0", + }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@happy-dom/global-registrator": "^15.11.0", @@ -125,6 +128,8 @@ "@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], "@volar/language-core": ["@volar/language-core@2.4.23", "", { "dependencies": { "@volar/source-map": "2.4.23" } }, "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ=="], @@ -187,6 +192,8 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-png": ["fast-png@7.0.1", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^6.0.0", "pako": "^2.1.0" } }, "sha512-aD5BELuxRrAPlRhb9V/z1PVMFJy3cUXqIvoxM3IQ+7Rku+T4cbXxWclZ47f1XwhViEl4n30TAN8JmvTJKKc2Dw=="], + "fs-extra": ["fs-extra@11.3.2", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -205,6 +212,8 @@ "import-lazy": ["import-lazy@4.0.0", "", {}, "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw=="], + "iobuffer": ["iobuffer@6.0.1", "", {}, "sha512-SZWYkWNfjIXIBYSDpXDYIgshqtbOPsi4lviawAEceR1Kqk+sHDlcQjWrzNQsii80AyBY0q5c8HCTNjqo74ul+Q=="], + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], @@ -235,6 +244,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], diff --git a/demo/bin/demo.js b/demo/bin/demo.js index e619a5e..06975f0 100644 --- a/demo/bin/demo.js +++ b/demo/bin/demo.js @@ -13,8 +13,13 @@ import { homedir } from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; -// Node-pty for cross-platform PTY support -import pty from '@lydell/node-pty'; +// Node-pty for cross-platform PTY support. The 1.2.0-beta.x line adds a +// `pixelSize` argument to resize(), which sets ws_xpixel / ws_ypixel in +// the slave PTY's winsize struct so kitty kittens (icat etc.) can detect +// graphics support via TIOCGWINSZ instead of falling back to terminal +// queries. Lydell's fork is based on 1.1.0-beta14 (pre-pixelSize), so we +// use upstream's beta directly. +import pty from 'node-pty'; // WebSocket server import { WebSocketServer } from 'ws'; @@ -243,12 +248,32 @@ const HTML_TEMPLATE = ` const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows; let ws; + // Read total canvas pixel dims (CSS pixels). The server stuffs these + // into ws_xpixel / ws_ypixel via node-pty's resize(cols, rows, pixelSize) + // so kittens like icat see non-zero TIOCGWINSZ pixel fields. + function getPixelSize() { + const canvas = container.querySelector('canvas'); + return canvas + ? { xpixel: canvas.clientWidth, ypixel: canvas.clientHeight } + : { xpixel: 0, ypixel: 0 }; + } + function connect() { setStatus('connecting', 'Connecting...'); ws = new WebSocket(wsUrl); ws.onopen = () => { setStatus('connected', 'Connected'); + // Push initial pixel dims so TIOCGWINSZ-gated tools see them + // before the first resize event. + const px = getPixelSize(); + ws.send(JSON.stringify({ + type: 'resize', + cols: term.cols, + rows: term.rows, + xpixel: px.xpixel, + ypixel: px.ypixel, + })); }; ws.onmessage = (event) => { @@ -278,7 +303,14 @@ const HTML_TEMPLATE = ` // Handle resize - notify PTY when terminal dimensions change term.onResize(({ cols, rows }) => { if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'resize', cols, rows })); + const px = getPixelSize(); + ws.send(JSON.stringify({ + type: 'resize', + cols, + rows, + xpixel: px.xpixel, + ypixel: px.ypixel, + })); } }); @@ -463,7 +495,18 @@ wss.on('connection', (ws, req) => { try { const msg = JSON.parse(message); if (msg.type === 'resize') { - ptyProcess.resize(msg.cols, msg.rows); + // node-pty 1.2.0+ accepts a third pixelSize arg that sets + // ws_xpixel / ws_ypixel in the PTY winsize struct. Without it, + // kitty kittens (icat, etc.) read zeros via TIOCGWINSZ and + // refuse to render images. + if (msg.xpixel > 0 && msg.ypixel > 0) { + ptyProcess.resize(msg.cols, msg.rows, { + width: msg.xpixel, + height: msg.ypixel, + }); + } else { + ptyProcess.resize(msg.cols, msg.rows); + } return; } } catch (e) { diff --git a/demo/bun.lock b/demo/bun.lock index 26dac1c..4aea4a4 100644 --- a/demo/bun.lock +++ b/demo/bun.lock @@ -5,28 +5,18 @@ "": { "name": "@ghostty-web/demo", "dependencies": { - "@lydell/node-pty": "^1.0.1", "ghostty-web": "latest", + "node-pty": "1.2.0-beta.12", "ws": "^8.18.0", }, }, }, "packages": { - "@lydell/node-pty": ["@lydell/node-pty@1.1.0", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.1.0", "@lydell/node-pty-darwin-x64": "1.1.0", "@lydell/node-pty-linux-arm64": "1.1.0", "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0" } }, "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw=="], - - "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w=="], - - "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA=="], - - "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg=="], - - "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA=="], - - "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w=="], + "ghostty-web": ["ghostty-web@0.2.1", "", {}, "sha512-wrovbPlHcl+nIkp7S7fY7vOTsmBjwMFihZEe2PJe/M6G4/EwuyJnwaWTTzNfuY7RcM/lVlN+PvGWqJIhKSB5hw=="], - "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], - "ghostty-web": ["ghostty-web@0.2.1", "", {}, "sha512-wrovbPlHcl+nIkp7S7fY7vOTsmBjwMFihZEe2PJe/M6G4/EwuyJnwaWTTzNfuY7RcM/lVlN+PvGWqJIhKSB5hw=="], + "node-pty": ["node-pty@1.2.0-beta.12", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-uExTCG/4VmSJa4+TjxFwPXv8BfacmfFEBL6JpxCMDghcwqzvD0yTcGmZ1fKOK6HY33tp0CelLblqTECJizc+Yw=="], "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], } diff --git a/demo/index.html b/demo/index.html index 2a0d323..ce5e9a1 100644 --- a/demo/index.html +++ b/demo/index.html @@ -138,6 +138,20 @@ let ws; let fitAddon; + // Read total canvas pixel dimensions (CSS pixels). Used so the + // server can stuff ws_xpixel / ws_ypixel into the PTY winsize via + // node-pty's resize(cols, rows, pixelSize) — kitty kittens (icat + // etc.) read those fields from their stdin and bail "doesn't + // support reporting screen sizes in pixels" if they're zero. + // Module-scope so both initTerminal and connectWebSocket can call it. + function getPixelSize() { + const container = document.getElementById('terminal-container'); + const canvas = container?.querySelector('canvas'); + return canvas + ? { xpixel: canvas.clientWidth, ypixel: canvas.clientHeight } + : { xpixel: 0, ypixel: 0 }; + } + async function initTerminal() { // Initialize WASM await init(); @@ -168,8 +182,16 @@ // Handle terminal resize term.onResize((size) => { if (ws && ws.readyState === WebSocket.OPEN) { - // Send resize as control sequence (server expects this format) - ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows })); + const px = getPixelSize(); + ws.send( + JSON.stringify({ + type: 'resize', + cols: size.cols, + rows: size.rows, + xpixel: px.xpixel, + ypixel: px.ypixel, + }) + ); } }); @@ -200,6 +222,19 @@ ws.onopen = () => { console.log('WebSocket connected'); updateConnectionStatus(true); + // Push initial pixel dims into the PTY winsize so tools that + // gate on TIOCGWINSZ (e.g. kitten icat) can detect graphics + // support without falling back to terminal queries. + const px = getPixelSize(); + ws.send( + JSON.stringify({ + type: 'resize', + cols: term.cols, + rows: term.rows, + xpixel: px.xpixel, + ypixel: px.ypixel, + }) + ); }; ws.onmessage = (event) => { diff --git a/demo/package.json b/demo/package.json index 09be088..7801f70 100644 --- a/demo/package.json +++ b/demo/package.json @@ -11,7 +11,7 @@ "dev": "node bin/demo.js --dev" }, "dependencies": { - "@lydell/node-pty": "^1.0.1", + "node-pty": "1.2.0-beta.12", "ghostty-web": "latest", "ws": "^8.18.0" }, diff --git a/ghostty b/ghostty index 5714ed0..6590196 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 5714ed07a1012573261b7b7e3ed2add9c1504496 +Subproject commit 6590196661f769dd8f2b3e85d6c98262c4ec5b3b diff --git a/lib/buffer.ts b/lib/buffer.ts index 031a493..bc52741 100644 --- a/lib/buffer.ts +++ b/lib/buffer.ts @@ -105,12 +105,14 @@ export class Buffer implements IBuffer { // Create a null cell (codepoint=0, default colors, no flags) const nullCellData: GhosttyCell = { codepoint: 0, - fg_r: 204, - fg_g: 204, - fg_b: 204, + fg_r: 0, + fg_g: 0, + fg_b: 0, bg_r: 0, bg_g: 0, bg_b: 0, + fgIsDefault: true, + bgIsDefault: true, flags: 0, width: 1, hyperlink_id: 0, @@ -245,12 +247,14 @@ export class BufferLine implements IBufferLine { return new BufferCell( { codepoint: 0, - fg_r: 204, - fg_g: 204, - fg_b: 204, + fg_r: 0, + fg_g: 0, + fg_b: 0, bg_r: 0, bg_g: 0, bg_b: 0, + fgIsDefault: true, + bgIsDefault: true, flags: 0, width: 1, hyperlink_id: 0, diff --git a/lib/ghostty.ts b/lib/ghostty.ts index f079885..29e92ff 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -6,22 +6,50 @@ * snapshot of all render data in a single update call. */ +import { decode as decodePng } from 'fast-png'; import { + CellData, CellFlags, + CellWide, type Cursor, + CursorVisualStyle, DirtyState, - GHOSTTY_CONFIG_SIZE, type GhosttyCell, type GhosttyTerminalConfig, type GhosttyWasmExports, + KITTY_PLACEMENT_RENDER_INFO_SIZE, KeyEncoderOption, type KeyEvent, + KittyGraphicsData, + KittyGraphicsImageData, + KittyGraphicsPlacementData, + type KittyImageFormat, + type KittyImagePixels, type KittyKeyFlags, + type KittyPlacementInfo, + PointTag, type RGB, type RenderStateColors, type RenderStateCursor, + RenderStateData, + RenderStateOption, + RenderStateRowData, + RenderStateRowOption, + RowCellsData, + RowData, + SysOption, + TerminalData, type TerminalHandle, + TerminalOption, + TerminalScreen, + packMode, } from './types'; +import { + type DecodePngCallback, + type SizeCallback, + type WritePtyCallback, + makeCallbackTrampolines, +} from './write_pty_trampoline'; // Re-export types for convenience export { @@ -255,19 +283,79 @@ export class GhosttyTerminal { private exports: GhosttyWasmExports; private memory: WebAssembly.Memory; private handle: TerminalHandle; + private renderHandle: number = 0; + private rowIter: number = 0; + private rowCells: number = 0; private _cols: number; private _rows: number; - /** Size of GhosttyCell in WASM (16 bytes) */ - private static readonly CELL_SIZE = 16; - - /** Reusable buffer for viewport operations */ - private viewportBufferPtr: number = 0; - private viewportBufferSize: number = 0; - /** Cell pool for zero-allocation rendering */ private cellPool: GhosttyCell[] = []; + /** + * Cell pixel dimensions last pushed to the WASM terminal via + * ghostty_terminal_resize. Zero means "unknown / disabled" — kitty + * graphics image sizing and CSI 14/16/18 t in-band size reports will + * return zero/no-op until setCellPixelSize() is called with real values. + */ + private cellWidthPx = 0; + private cellHeightPx = 0; + + /** + * Per-row dirty state for the current render-state snapshot. Cleared on + * update() and populated lazily by isRowDirty() (or as a side effect of + * getViewport, which iterates rows anyway). + */ + private rowDirtyCache: boolean[] | null = null; + + /** + * Per-row soft-wrap state for the current render-state snapshot. Same + * lifecycle as rowDirtyCache; the two caches are filled in lockstep. + */ + private rowWrapCache: boolean[] | null = null; + + /** + * Bytes the terminal would have written back to a real PTY in response + * to query sequences (DSR, XTVERSION, in-band size reports, ...). + * Captured by the WRITE_PTY callback installed in the constructor and + * drained by readResponse(). Each slot is one callback invocation, so + * a single response sequence may span multiple slots. + */ + private pendingResponses: Uint8Array[] = []; + + /** + * Per-table registry for callback trampolines. Keyed on the WASM + * module's __indirect_function_table so that multiple Ghostty.load() + * instances each get their own trampoline slots and routing map — + * terminal handles are only unique within a single WASM instance, and + * indices into one module's table are meaningless in another. + * + * One trampoline pair (write_pty + size) is installed per table; their + * slot indices live here alongside the routing map. The dispatchers + * close over the same instancesByHandle so any GhosttyTerminal coming + * from this WASM module routes correctly. + */ + private static callbackRegistries = new WeakMap< + WebAssembly.Table, + { + writePtyIndex: number; + sizeIndex: number; + decodePngIndex: number; + instancesByHandle: Map; + } + >(); + + /** + * Cached pointer to this terminal's registry. We only need it to + * deregister cleanly in free() / cleanupOnConstructorFailure(). + */ + private callbackRegistry?: { + writePtyIndex: number; + sizeIndex: number; + decodePngIndex: number; + instancesByHandle: Map; + }; + constructor( exports: GhosttyWasmExports, memory: WebAssembly.Memory, @@ -280,52 +368,270 @@ export class GhosttyTerminal { this._cols = cols; this._rows = rows; - if (config) { - // Allocate config struct in WASM memory - const configPtr = this.exports.ghostty_wasm_alloc_u8_array(GHOSTTY_CONFIG_SIZE); - if (configPtr === 0) { - throw new Error('Failed to allocate config (out of memory)'); - } + // GhosttyTerminalOptions layout (8 bytes on wasm32): + // u16 cols @ 0 + // u16 rows @ 2 + // u32 max_scrollback @ 4 (size_t is u32 on wasm32) + const TERM_OPTS_SIZE = 8; + const optsPtr = this.exports.ghostty_wasm_alloc_u8_array(TERM_OPTS_SIZE); + if (optsPtr === 0) throw new Error('Failed to allocate terminal options'); + const termPtrPtr = this.exports.ghostty_wasm_alloc_opaque(); + if (termPtrPtr === 0) { + this.exports.ghostty_wasm_free_u8_array(optsPtr, TERM_OPTS_SIZE); + throw new Error('Failed to allocate terminal handle'); + } + try { + const optsView = new DataView(this.memory.buffer, optsPtr, TERM_OPTS_SIZE); + optsView.setUint16(0, cols, true); + optsView.setUint16(2, rows, true); + optsView.setUint32(4, config?.scrollbackLimit ?? 10000, true); - try { - // Write config to WASM memory - const view = new DataView(this.memory.buffer); - let offset = configPtr; + const result = this.exports.ghostty_terminal_new(0, termPtrPtr, optsPtr); + if (result !== 0) throw new Error(`ghostty_terminal_new failed: ${result}`); - // scrollback_limit (u32) - view.setUint32(offset, config.scrollbackLimit ?? 10000, true); - offset += 4; + this.handle = new DataView(this.memory.buffer).getUint32(termPtrPtr, true); + } finally { + this.exports.ghostty_wasm_free_u8_array(optsPtr, TERM_OPTS_SIZE); + this.exports.ghostty_wasm_free_opaque(termPtrPtr); + } - // fg_color (u32) - view.setUint32(offset, config.fgColor ?? 0, true); - offset += 4; + if (!this.handle) throw new Error('Failed to create terminal'); - // bg_color (u32) - view.setUint32(offset, config.bgColor ?? 0, true); - offset += 4; + // Everything below could fail; if it does we need to undo the + // post-terminal_new init (registry entry, callback wiring) in + // addition to the WASM resource frees that cleanupOnConstructorFailure + // already handles. + try { + // Install the trampoline callbacks so the terminal can deliver + // response bytes (DSR, XTVERSION, etc.) back to JS via WRITE_PTY, + // and so the embedder can answer XTWINOPS size queries (CSI 14/16/18 t) + // via SIZE. Resolves / creates the per-table registry on first + // use, registers `this` in it, then sets the options on the terminal. + this.installCallbacks(); + + // Apply theme colors + palette overrides. The constructor's options + // struct only carries cols/rows/scrollback, so colors land here via + // ghostty_terminal_set(COLOR_*). + if (config) this.applyConfig(config); + + // Mode 2027 (grapheme clustering) is what lets the terminal treat + // multi-codepoint clusters (flag emoji, ZWJ sequences, skin tones) + // as a single cell. Coder's old C-side patch enabled it inside the + // terminal_new() shim; the new public C ABI doesn't, so we enable + // it here from JS to preserve coder's defaults. + this.exports.ghostty_terminal_mode_set(this.handle, packMode(2027, false), true); + + // Enable kitty graphics by giving the terminal a non-zero image + // storage limit. The new C ABI ships kitty graphics disabled by + // default — image transmission commands are silently dropped at + // parse time until this limit is set. 64MB is enough for typical + // TUI use and matches what coder's old WASM defaulted to. + this.setKittyImageStorageLimit(64 * 1024 * 1024); + } catch (e) { + this.cleanupOnConstructorFailure(); + throw e; + } - // cursor_color (u32) - view.setUint32(offset, config.cursorColor ?? 0, true); - offset += 4; + // Create the render state that owns the per-frame snapshot read by + // getCursor/getColors/getViewport. Render state is updated explicitly via + // update() rather than implicitly per read, since it's relatively cheap + // when the terminal hasn't changed but still costs a WASM crossing. + this.renderHandle = this.allocOpaqueOrFail('ghostty_render_state_new', (out) => + this.exports.ghostty_render_state_new(0, out) + ); + // Pre-allocate the row iterator and row-cells iterators once and reuse + // them across frames. They're populated from the render state in + // getViewport via _get(ROW_ITERATOR) and _row_get(ROW_DATA_CELLS); the + // handles themselves stay live for the terminal's lifetime. + this.rowIter = this.allocOpaqueOrFail('ghostty_render_state_row_iterator_new', (out) => + this.exports.ghostty_render_state_row_iterator_new(0, out) + ); + this.rowCells = this.allocOpaqueOrFail('ghostty_render_state_row_cells_new', (out) => + this.exports.ghostty_render_state_row_cells_new(0, out) + ); - // palette[16] (u32 * 16) - for (let i = 0; i < 16; i++) { - view.setUint32(offset, config.palette?.[i] ?? 0, true); - offset += 4; - } + this.initCellPool(); + } - this.handle = this.exports.ghostty_terminal_new_with_config(cols, rows, configPtr); + /** + * Allocate an opaque handle through one of the new(allocator, *outHandle) + * factory functions. Wraps the boilerplate of: alloc out-pointer, call + * factory, check Result, read the handle, free out-pointer. + * + * If the factory call fails, frees any already-acquired terminal/render + * resources so the caller-throwing flow doesn't leak across the partially + * constructed object. + */ + private allocOpaqueOrFail(name: string, factory: (outPtr: number) => number): number { + const outPtr = this.exports.ghostty_wasm_alloc_opaque(); + if (outPtr === 0) { + this.cleanupOnConstructorFailure(); + throw new Error(`Failed to allocate handle for ${name}`); + } + try { + const r = factory(outPtr); + if (r !== 0) { + this.cleanupOnConstructorFailure(); + throw new Error(`${name} failed: ${r}`); + } + return new DataView(this.memory.buffer).getUint32(outPtr, true); + } finally { + this.exports.ghostty_wasm_free_opaque(outPtr); + } + } + + /** + * Apply user-supplied colors + palette overrides to the freshly-created + * terminal via ghostty_terminal_set(COLOR_*). + * + * For the palette: the new C ABI takes a full 256-entry array, but coder's + * config carries only the legacy 16 ANSI entries (each as a 0xRRGGBB int, + * 0 meaning "use default"). To preserve indices ≥16 we read the existing + * default palette first, overlay the non-zero entries from config, and + * write the merged 768-byte buffer back. + */ + private applyConfig(config: GhosttyTerminalConfig): void { + if (config.fgColor) this.setColorOption(TerminalOption.COLOR_FOREGROUND, config.fgColor); + if (config.bgColor) this.setColorOption(TerminalOption.COLOR_BACKGROUND, config.bgColor); + if (config.cursorColor) { + this.setColorOption(TerminalOption.COLOR_CURSOR, config.cursorColor); + } + + if (config.palette && config.palette.some((v) => v !== 0)) { + const PALETTE_SIZE = 256 * 3; + const ptr = this.exports.ghostty_wasm_alloc_u8_array(PALETTE_SIZE); + try { + // Seed from the upstream default palette so untouched indices + // keep their canonical ANSI colors. + const seedRes = this.exports.ghostty_terminal_get( + this.handle, + TerminalData.COLOR_PALETTE_DEFAULT, + ptr + ); + if (seedRes !== 0) { + // Couldn't read defaults — fall back to all-black so we don't + // smear stale memory into the palette. + new Uint8Array(this.memory.buffer, ptr, PALETTE_SIZE).fill(0); + } + const buf = new Uint8Array(this.memory.buffer, ptr, PALETTE_SIZE); + const limit = Math.min(config.palette.length, 16); + for (let i = 0; i < limit; i++) { + const c = config.palette[i]!; + if (c === 0) continue; // 0 = "leave default in place" + buf[i * 3 + 0] = (c >> 16) & 0xff; + buf[i * 3 + 1] = (c >> 8) & 0xff; + buf[i * 3 + 2] = c & 0xff; + } + this.exports.ghostty_terminal_set(this.handle, TerminalOption.COLOR_PALETTE, ptr); } finally { - // Free the config memory - this.exports.ghostty_wasm_free_u8_array(configPtr, GHOSTTY_CONFIG_SIZE); + this.exports.ghostty_wasm_free_u8_array(ptr, PALETTE_SIZE); } - } else { - this.handle = this.exports.ghostty_terminal_new(cols, rows); } + } - if (!this.handle) throw new Error('Failed to create terminal'); + private setColorOption(opt: TerminalOption, rgb: number): void { + const ptr = this.exports.ghostty_wasm_alloc_u8_array(3); + const buf = new Uint8Array(this.memory.buffer, ptr, 3); + buf[0] = (rgb >> 16) & 0xff; + buf[1] = (rgb >> 8) & 0xff; + buf[2] = rgb & 0xff; + this.exports.ghostty_terminal_set(this.handle, opt, ptr); + this.exports.ghostty_wasm_free_u8_array(ptr, 3); + } - this.initCellPool(); + /** + * Release any resources that have been allocated by the constructor up to + * this point. Called when a subsequent step fails so we don't leak handles + * before the throw propagates. + */ + private cleanupOnConstructorFailure(): void { + if (this.callbackRegistry) { + this.callbackRegistry.instancesByHandle.delete(this.handle); + this.callbackRegistry = undefined; + } + if (this.rowCells) { + this.exports.ghostty_render_state_row_cells_free(this.rowCells); + this.rowCells = 0; + } + if (this.rowIter) { + this.exports.ghostty_render_state_row_iterator_free(this.rowIter); + this.rowIter = 0; + } + if (this.renderHandle) { + this.exports.ghostty_render_state_free(this.renderHandle); + this.renderHandle = 0; + } + if (this.handle) { + this.exports.ghostty_terminal_free(this.handle); + } + } + + // ========================================================================== + // RenderState scratch helpers + // + // The new render-state API exposes a single ghostty_render_state_get(state, + // key, *out) entry point keyed by GhosttyRenderStateData. Each helper + // allocates a small scratch buffer of the right size, performs the read, + // and frees. Per-call allocation is intentionally simple; if profiling + // shows it's hot, we can replace these with a single reusable scratch + // buffer carved up by offset. + // ========================================================================== + + private rsGetU8(key: number): number { + const p = this.exports.ghostty_wasm_alloc_u8(); + this.exports.ghostty_render_state_get(this.renderHandle, key, p); + const v = new DataView(this.memory.buffer).getUint8(p); + this.exports.ghostty_wasm_free_u8(p); + return v; + } + + private rsGetU16(key: number): number { + const p = this.exports.ghostty_wasm_alloc_u8_array(2); + this.exports.ghostty_render_state_get(this.renderHandle, key, p); + const v = new DataView(this.memory.buffer).getUint16(p, true); + this.exports.ghostty_wasm_free_u8_array(p, 2); + return v; + } + + private rsGetU32(key: number): number { + const p = this.exports.ghostty_wasm_alloc_u8_array(4); + this.exports.ghostty_render_state_get(this.renderHandle, key, p); + const v = new DataView(this.memory.buffer).getUint32(p, true); + this.exports.ghostty_wasm_free_u8_array(p, 4); + return v; + } + + private rsGetRgb(key: number): RGB { + const p = this.exports.ghostty_wasm_alloc_u8_array(3); + this.exports.ghostty_render_state_get(this.renderHandle, key, p); + const buf = new Uint8Array(this.memory.buffer, p, 3); + const rgb: RGB = { r: buf[0]!, g: buf[1]!, b: buf[2]! }; + this.exports.ghostty_wasm_free_u8_array(p, 3); + return rgb; + } + + // ========================================================================== + // Terminal property scratch helpers + // + // Same pattern as rsGet* but against ghostty_terminal_get(terminal, key, + // *out). The TerminalData enum encodes the value type; pick the matching + // helper by output size. + // ========================================================================== + + private tGetU8(key: number): number { + const p = this.exports.ghostty_wasm_alloc_u8(); + this.exports.ghostty_terminal_get(this.handle, key, p); + const v = new DataView(this.memory.buffer).getUint8(p); + this.exports.ghostty_wasm_free_u8(p); + return v; + } + + private tGetU32(key: number): number { + const p = this.exports.ghostty_wasm_alloc_u8_array(4); + this.exports.ghostty_terminal_get(this.handle, key, p); + const v = new DataView(this.memory.buffer).getUint32(p, true); + this.exports.ghostty_wasm_free_u8_array(p, 4); + return v; } get cols(): number { @@ -343,7 +649,7 @@ export class GhosttyTerminal { const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; const ptr = this.exports.ghostty_wasm_alloc_u8_array(bytes.length); new Uint8Array(this.memory.buffer).set(bytes, ptr); - this.exports.ghostty_terminal_write(this.handle, ptr, bytes.length); + this.exports.ghostty_terminal_vt_write(this.handle, ptr, bytes.length); this.exports.ghostty_wasm_free_u8_array(ptr, bytes.length); } @@ -351,15 +657,267 @@ export class GhosttyTerminal { if (cols === this._cols && rows === this._rows) return; this._cols = cols; this._rows = rows; - this.exports.ghostty_terminal_resize(this.handle, cols, rows); - this.invalidateBuffers(); + this.exports.ghostty_terminal_resize( + this.handle, + cols, + rows, + this.cellWidthPx, + this.cellHeightPx + ); this.initCellPool(); } + /** + * Set the maximum bytes of image data the terminal will retain across + * all kitty graphics images. Zero disables kitty graphics entirely + * (transmissions will be parsed and dropped). Set this BEFORE any + * image-bearing data is written to the terminal — there's no + * retroactive recovery of dropped images. + * + * Input is uint64_t* on the C side, so we use a u32-pair little-endian + * write to keep the byte count exact even past 4GB (probably overkill + * but free). + */ + setKittyImageStorageLimit(bytes: number): void { + const ptr = this.exports.ghostty_wasm_alloc_u8_array(8); + const view = new DataView(this.memory.buffer); + const lo = bytes >>> 0; + const hi = Math.floor(bytes / 0x100000000) >>> 0; + view.setUint32(ptr + 0, lo, true); + view.setUint32(ptr + 4, hi, true); + this.exports.ghostty_terminal_set(this.handle, TerminalOption.KITTY_IMAGE_STORAGE_LIMIT, ptr); + this.exports.ghostty_wasm_free_u8_array(ptr, 8); + } + + // ========================================================================== + // Kitty graphics — placement iteration + image data lookup. + // + // The renderer calls these per frame: iterate visible placements, look up + // pixel data for each, composite onto the canvas. All handles returned + // here (storage, image) are borrowed from the terminal and invalidated by + // ANY mutating terminal call (vt_write, resize, reset, ...). + // Callers must finish any read/copy before the next mutation. + // ========================================================================== + + /** + * Get the kitty graphics storage handle for the active screen, or null + * if storage is disabled or no images are stored. Cheap to call; returns + * a borrowed pointer. + */ + getKittyGraphics(): number | null { + const out = this.exports.ghostty_wasm_alloc_u8_array(4); + try { + const r = this.exports.ghostty_terminal_get(this.handle, TerminalData.KITTY_GRAPHICS, out); + if (r !== 0) return null; + const handle = new DataView(this.memory.buffer).getUint32(out, true); + return handle === 0 ? null : handle; + } finally { + this.exports.ghostty_wasm_free_u8_array(out, 4); + } + } + + /** + * Iterate placements in the active screen, yielding render-ready info + * for each. The optional `onlyVisible` flag (default true) drops + * placements that don't intersect the viewport — most renderers want + * this. Use `false` if you need to track invalidated regions for + * partial damage. + * + * Internally this uses the upstream placement iterator + the one-shot + * placement_render_info call (fills 12 fields in one WASM crossing + * instead of 5 separate getters). + */ + *iterPlacements(graphics: number, onlyVisible: boolean = true): Generator { + // Allocate iterator + scratch buffers once for the whole walk. + const iterPP = this.exports.ghostty_wasm_alloc_opaque(); + if (iterPP === 0) return; + let iter = 0; + try { + const r = this.exports.ghostty_kitty_graphics_placement_iterator_new(0, iterPP); + if (r !== 0) return; + iter = new DataView(this.memory.buffer).getUint32(iterPP, true); + if (iter === 0) return; + + // Bind the iterator to the current placements. + const handlePtr = this.exports.ghostty_wasm_alloc_u8_array(4); + try { + new DataView(this.memory.buffer).setUint32(handlePtr, iter, true); + this.exports.ghostty_kitty_graphics_get( + graphics, + KittyGraphicsData.PLACEMENT_ITERATOR, + handlePtr + ); + } finally { + this.exports.ghostty_wasm_free_u8_array(handlePtr, 4); + } + + const idPtr = this.exports.ghostty_wasm_alloc_u8_array(4); + const infoPtr = this.exports.ghostty_wasm_alloc_u8_array(KITTY_PLACEMENT_RENDER_INFO_SIZE); + // Sized struct: write the discriminator once, the populator + // overwrites the rest each call. + new DataView(this.memory.buffer).setUint32(infoPtr, KITTY_PLACEMENT_RENDER_INFO_SIZE, true); + try { + while (this.exports.ghostty_kitty_graphics_placement_next(iter)) { + // Look up image_id for this placement so we can pair it with + // pixel data in the caller. + this.exports.ghostty_kitty_graphics_placement_get( + iter, + KittyGraphicsPlacementData.IMAGE_ID, + idPtr + ); + const imageId = new DataView(this.memory.buffer).getUint32(idPtr, true); + + // Resolve the image handle — placement_render_info needs it. + const imageHandle = this.exports.ghostty_kitty_graphics_image(graphics, imageId); + if (imageHandle === 0) continue; + + // Reset the size discriminator (the populator may have written + // the actual struct size back, but we don't rely on that — be + // explicit so the call always sees the buffer as fully owned). + new DataView(this.memory.buffer).setUint32( + infoPtr, + KITTY_PLACEMENT_RENDER_INFO_SIZE, + true + ); + const r2 = this.exports.ghostty_kitty_graphics_placement_render_info( + iter, + imageHandle, + this.handle, + infoPtr + ); + if (r2 !== 0) continue; + + // Fetch is_virtual via a separate placement_get — it isn't + // in the PlacementRenderInfo struct (which assumes a real + // viewport position). + this.exports.ghostty_kitty_graphics_placement_get( + iter, + KittyGraphicsPlacementData.IS_VIRTUAL, + idPtr // reuse the 4-byte slot; the value is a bool but written as u8 + ); + const isVirtual = new DataView(this.memory.buffer).getUint8(idPtr) !== 0; + + const v = new DataView(this.memory.buffer); + const info: KittyPlacementInfo = { + imageId, + pixelWidth: v.getUint32(infoPtr + 4, true), + pixelHeight: v.getUint32(infoPtr + 8, true), + gridCols: v.getUint32(infoPtr + 12, true), + gridRows: v.getUint32(infoPtr + 16, true), + viewportCol: v.getInt32(infoPtr + 20, true), + viewportRow: v.getInt32(infoPtr + 24, true), + viewportVisible: v.getUint8(infoPtr + 28) !== 0, + sourceX: v.getUint32(infoPtr + 32, true), + sourceY: v.getUint32(infoPtr + 36, true), + sourceWidth: v.getUint32(infoPtr + 40, true), + sourceHeight: v.getUint32(infoPtr + 44, true), + isVirtual, + }; + // onlyVisible filter: keep only visible direct placements. Virtual + // placements don't have a viewport position so viewportVisible + // is always false; callers walking unicode-placeholder grids + // pass onlyVisible=false to receive them. + if (onlyVisible && !info.viewportVisible) continue; + yield info; + } + } finally { + this.exports.ghostty_wasm_free_u8_array(idPtr, 4); + this.exports.ghostty_wasm_free_u8_array(infoPtr, KITTY_PLACEMENT_RENDER_INFO_SIZE); + } + } finally { + if (iter !== 0) { + this.exports.ghostty_kitty_graphics_placement_iterator_free(iter); + } + this.exports.ghostty_wasm_free_opaque(iterPP); + } + } + + /** + * Get the pixel data + metadata for an image by id. Returns null if the + * image isn't stored or isn't in a format we can hand the renderer + * directly (RGB / RGBA / GRAY / GRAY_ALPHA). + * + * The returned `data` is a borrowed view into WASM memory — copy before + * the next vt_write if you need to retain. Most callers will turn this + * into an ImageData / canvas immediately and discard the view. + */ + getKittyImagePixels(graphics: number, imageId: number): KittyImagePixels | null { + const image = this.exports.ghostty_kitty_graphics_image(graphics, imageId); + if (image === 0) return null; + + const u32Ptr = this.exports.ghostty_wasm_alloc_u8_array(4); + try { + const view = new DataView(this.memory.buffer); + const read = (key: number): number => { + if (this.exports.ghostty_kitty_graphics_image_get(image, key, u32Ptr) !== 0) { + return 0; + } + return new DataView(this.memory.buffer).getUint32(u32Ptr, true); + }; + + const width = read(KittyGraphicsImageData.WIDTH); + const height = read(KittyGraphicsImageData.HEIGHT); + const format = read(KittyGraphicsImageData.FORMAT) as KittyImageFormat; + const dataPtr = read(KittyGraphicsImageData.DATA_PTR); + const dataLen = read(KittyGraphicsImageData.DATA_LEN); + void view; + + if (width === 0 || height === 0 || dataPtr === 0 || dataLen === 0) { + return null; + } + + return { + width, + height, + format, + data: new Uint8Array(this.memory.buffer, dataPtr, dataLen), + }; + } finally { + this.exports.ghostty_wasm_free_u8_array(u32Ptr, 4); + } + } + + /** + * Push the renderer's per-cell pixel size into the WASM terminal. + * + * The new C ABI doesn't expose a separate "set pixel size" call — + * dimensions only flow through ghostty_terminal_resize, which takes + * (cols, rows, cell_width_px, cell_height_px). We cache the cell pixel + * dims on the instance so subsequent resize() calls keep the values + * stable, and short-circuit when nothing has changed. + * + * The width/height arguments are PER-CELL CSS pixels — matches what + * the renderer reports via getMetrics(). Coder's old setPixelSize + * took TOTAL screen pixels (cell_width * cols, cell_height * rows); + * we renamed to avoid silent value mis-passing. + * + * Affects in-band size reports (CSI 14/16/18 t) and kitty graphics + * placement sizing. Until called, those query paths return zero. + */ + setCellPixelSize(cellWidthPx: number, cellHeightPx: number): void { + const w = Math.max(1, Math.round(cellWidthPx)); + const h = Math.max(1, Math.round(cellHeightPx)); + if (w === this.cellWidthPx && h === this.cellHeightPx) return; + this.cellWidthPx = w; + this.cellHeightPx = h; + this.exports.ghostty_terminal_resize(this.handle, this._cols, this._rows, w, h); + } + free(): void { - if (this.viewportBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); - this.viewportBufferPtr = 0; + if (this.callbackRegistry) { + this.callbackRegistry.instancesByHandle.delete(this.handle); + } + if (this.rowCells) { + this.exports.ghostty_render_state_row_cells_free(this.rowCells); + this.rowCells = 0; + } + if (this.rowIter) { + this.exports.ghostty_render_state_row_iterator_free(this.rowIter); + this.rowIter = 0; + } + if (this.renderHandle) { + this.exports.ghostty_render_state_free(this.renderHandle); + this.renderHandle = 0; } this.exports.ghostty_terminal_free(this.handle); } @@ -382,94 +940,446 @@ export class GhosttyTerminal { * Safe to call multiple times - dirty state persists until markClean(). */ update(): DirtyState { - return this.exports.ghostty_render_state_update(this.handle) as DirtyState; + const r = this.exports.ghostty_render_state_update(this.renderHandle, this.handle); + if (r !== 0) throw new Error(`ghostty_render_state_update failed: ${r}`); + // Per-row caches are tied to the previous snapshot. + this.rowDirtyCache = null; + this.rowWrapCache = null; + // GhosttyRenderStateDirty is a 4-byte enum (FALSE=0, PARTIAL=1, FULL=2). + return this.rsGetU32(RenderStateData.DIRTY) as DirtyState; } /** * Get cursor state from render state. - * Ensures render state is fresh by calling update(). + * Calls update() first; safe to call repeatedly within a frame. */ getCursor(): RenderStateCursor { - // Call update() to ensure render state is fresh. - // This is safe to call multiple times - dirty state persists until markClean(). this.update(); + + const inViewport = this.rsGetU8(RenderStateData.CURSOR_VIEWPORT_HAS_VALUE) !== 0; + const visible = this.rsGetU8(RenderStateData.CURSOR_VISIBLE) !== 0; + const blinking = this.rsGetU8(RenderStateData.CURSOR_BLINKING) !== 0; + const styleRaw = this.rsGetU32(RenderStateData.CURSOR_VISUAL_STYLE); + + const viewportX = inViewport ? this.rsGetU16(RenderStateData.CURSOR_VIEWPORT_X) : -1; + const viewportY = inViewport ? this.rsGetU16(RenderStateData.CURSOR_VIEWPORT_Y) : -1; + + // Coder's interface only knows three styles; collapse BLOCK_HOLLOW into block. + const style: RenderStateCursor['style'] = + styleRaw === CursorVisualStyle.BAR + ? 'bar' + : styleRaw === CursorVisualStyle.UNDERLINE + ? 'underline' + : 'block'; + return { - x: this.exports.ghostty_render_state_get_cursor_x(this.handle), - y: this.exports.ghostty_render_state_get_cursor_y(this.handle), - viewportX: this.exports.ghostty_render_state_get_cursor_x(this.handle), - viewportY: this.exports.ghostty_render_state_get_cursor_y(this.handle), - visible: this.exports.ghostty_render_state_get_cursor_visible(this.handle), - blinking: false, // TODO: Add blinking support - style: 'block', // TODO: Add style support + x: Math.max(0, viewportX), + y: Math.max(0, viewportY), + viewportX, + viewportY, + visible, + blinking, + style, }; } /** - * Get default colors from render state + * Get default fg/bg/cursor colors from render state. */ getColors(): RenderStateColors { - const bg = this.exports.ghostty_render_state_get_bg_color(this.handle); - const fg = this.exports.ghostty_render_state_get_fg_color(this.handle); - return { - background: { - r: (bg >> 16) & 0xff, - g: (bg >> 8) & 0xff, - b: bg & 0xff, - }, - foreground: { - r: (fg >> 16) & 0xff, - g: (fg >> 8) & 0xff, - b: fg & 0xff, - }, - cursor: null, // TODO: Add cursor color support - }; + this.update(); + const background = this.rsGetRgb(RenderStateData.COLOR_BACKGROUND); + const foreground = this.rsGetRgb(RenderStateData.COLOR_FOREGROUND); + const hasCursor = this.rsGetU8(RenderStateData.COLOR_CURSOR_HAS_VALUE) !== 0; + const cursor = hasCursor ? this.rsGetRgb(RenderStateData.COLOR_CURSOR) : null; + return { background, foreground, cursor }; } /** - * Check if a specific row is dirty + * Check if a specific row is dirty. + * + * Backed by a per-row cache populated lazily — first call after update() + * walks the iterator once and reads the dirty flag for each row, then + * subsequent calls are O(1). getViewport() also populates the cache as a + * side effect so a typical "update → for-each-row isRowDirty → getViewport" + * render loop only iterates rows once. */ isRowDirty(y: number): boolean { - return this.exports.ghostty_render_state_is_row_dirty(this.handle, y); + if (y < 0 || y >= this._rows) return false; + if (this.rowDirtyCache === null) this.refreshRowMetaCache(); + return this.rowDirtyCache![y] ?? false; } /** - * Mark render state as clean (call after rendering) + * Check if a row is soft-wrapped (continues onto the next row). + * + * Same cache discipline as isRowDirty: lazy-populated on first call after + * update(), or as a side effect of getViewport. */ - markClean(): void { - this.exports.ghostty_render_state_mark_clean(this.handle); + isRowWrapped(y: number): boolean { + if (y < 0 || y >= this._rows) return false; + if (this.rowWrapCache === null) this.refreshRowMetaCache(); + return this.rowWrapCache![y] ?? false; } /** - * Get ALL viewport cells in ONE WASM call - the key performance optimization! - * Returns a reusable cell array (zero allocation after warmup). + * Walk the row iterator once and capture per-row dirty + wrap flags. + * + * Calls update() first since callers (isRowDirty / isRowWrapped) typically + * query right after a terminal write, before any explicit render-state + * refresh has happened. Same idempotency guarantee as getCursor/getColors: + * if no terminal change occurred since the last update, this is cheap. + * + * Reads ROW_DATA_DIRTY directly from the iterator, then ROW_DATA_RAW to + * obtain the GhosttyRow (u64) needed to call ghostty_row_get(WRAP_*). The + * row value is only valid for the current iterator position; we read it + * inline before advancing. */ - getViewport(): GhosttyCell[] { - const totalCells = this._cols * this._rows; - const neededSize = totalCells * GhosttyTerminal.CELL_SIZE; + private refreshRowMetaCache(): void { + this.update(); + const dirty = new Array(this._rows).fill(false); + const wrap = new Array(this._rows).fill(false); + this.populateHandle( + (out) => + this.exports.ghostty_render_state_get(this.renderHandle, RenderStateData.ROW_ITERATOR, out), + this.rowIter + ); + const dirtyPtr = this.exports.ghostty_wasm_alloc_u8(); + const rawPtr = this.exports.ghostty_wasm_alloc_u8_array(8); // GhosttyRow = u64 + const wrapPtr = this.exports.ghostty_wasm_alloc_u8(); + try { + let row = 0; + while ( + row < this._rows && + this.exports.ghostty_render_state_row_iterator_next(this.rowIter) + ) { + const view = new DataView(this.memory.buffer); + + this.exports.ghostty_render_state_row_get(this.rowIter, RenderStateRowData.DIRTY, dirtyPtr); + dirty[row] = view.getUint8(dirtyPtr) !== 0; + + this.exports.ghostty_render_state_row_get(this.rowIter, RenderStateRowData.RAW, rawPtr); + const rowU64 = new DataView(this.memory.buffer).getBigUint64(rawPtr, true); + this.exports.ghostty_row_get(rowU64, RowData.WRAP_CONTINUATION, wrapPtr); + wrap[row] = new DataView(this.memory.buffer).getUint8(wrapPtr) !== 0; - // Ensure buffer is allocated - if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { - if (this.viewportBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); + row++; } - this.viewportBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(neededSize); - this.viewportBufferSize = neededSize; + } finally { + this.exports.ghostty_wasm_free_u8(dirtyPtr); + this.exports.ghostty_wasm_free_u8_array(rawPtr, 8); + this.exports.ghostty_wasm_free_u8(wrapPtr); } + this.rowDirtyCache = dirty; + this.rowWrapCache = wrap; + } - // Get all cells in one call - const count = this.exports.ghostty_render_state_get_viewport( - this.handle, - this.viewportBufferPtr, - totalCells + /** + * Mark render state as clean — clears both global and per-row dirty. + * + * Per the upstream contract, "setting one dirty state doesn't unset the + * other." Global dirty is cleared via _set(OPTION_DIRTY, FALSE); per-row + * dirty is cleared by walking the row iterator and calling _row_set on + * each. Without the per-row pass, the next update() would still report + * the old per-row flags as dirty even though the terminal hasn't changed. + */ + markClean(): void { + const p = this.exports.ghostty_wasm_alloc_u8_array(4); + new DataView(this.memory.buffer).setUint32(p, DirtyState.NONE, true); + this.exports.ghostty_render_state_set(this.renderHandle, RenderStateOption.DIRTY, p); + this.exports.ghostty_wasm_free_u8_array(p, 4); + + // Re-bind the iterator to the current state and clear each row's dirty. + this.populateHandle( + (out) => + this.exports.ghostty_render_state_get(this.renderHandle, RenderStateData.ROW_ITERATOR, out), + this.rowIter + ); + const falsePtr = this.exports.ghostty_wasm_alloc_u8(); + new DataView(this.memory.buffer).setUint8(falsePtr, 0); + while (this.exports.ghostty_render_state_row_iterator_next(this.rowIter)) { + this.exports.ghostty_render_state_row_set(this.rowIter, RenderStateRowOption.DIRTY, falsePtr); + } + this.exports.ghostty_wasm_free_u8(falsePtr); + + // Caches captured the now-stale "dirty" state. + this.rowDirtyCache = null; + } + + /** + * Populate the cellPool from the current render state and return it. + * + * The new C ABI replaces coder's single ghostty_render_state_get_viewport() + * buffer-fill with a row iterator + per-row cells iterator. We allocate + * both iterators once at construction time and re-populate them per call: + * + * _get(state, ROW_ITERATOR, &rowIter) + * while (row_iterator_next(rowIter)) { + * _row_get(rowIter, ROW_DATA_CELLS, &rowCells) + * while (row_cells_next(rowCells)) { + * _row_cells_get(rowCells, GRAPHEMES_LEN, &len) + * _row_cells_get(rowCells, GRAPHEMES_BUF, &codepoint) // if len > 0 + * _row_cells_get(rowCells, FG_COLOR/BG_COLOR, &rgb) // INVALID_VALUE if unset + * } + * } + * + * This is intentionally minimal: we capture codepoint + fg/bg only. + * Style flags, cell width (double-width), and hyperlink IDs are deferred + * — they require parsing the GhosttyStyle sized struct and the per-cell + * ghostty_cell_get(WIDE)/HAS_HYPERLINK paths. The cellPool fields keep + * placeholder defaults (flags=0, width=1, hyperlink_id=0). + * + * Performance: ~3-4 WASM crossings per visible cell. For an 80x24 viewport + * that's ~6k crossings per frame. Profile before optimizing — likely + * candidates are _row_cells_get_multi for batched reads, or RAW + a + * cached layout map for direct memory access. + */ + getViewport(): GhosttyCell[] { + this.update(); + + // Pre-zero the pool so cells we don't visit (iterator ends early, or + // we exceed the configured cols/rows) read as empty. + this.zeroCellPool(); + + // Populate the row iterator from the render state. + // _get(state, ROW_ITERATOR, &iter) reads `*ptr` to get our pre-allocated + // iterator handle, then re-binds it to the current frame's row data. + this.populateHandle( + (out) => + this.exports.ghostty_render_state_get(this.renderHandle, RenderStateData.ROW_ITERATOR, out), + this.rowIter ); - if (count < 0) return this.cellPool; + // Reusable scratch buffers — declared once outside the loops since cell + // counts are dominant. 4 bytes covers u32 (grapheme len, codepoint). + // 3 bytes covers GhosttyColorRgb. 1 byte covers per-row dirty bool. + // Style is a 72-byte sized struct: write its `size` field once and the + // populator fills the rest each call (layout from ghostty_type_json: + // bold@56, italic@57, faint@58, blink@59, inverse@60, + // invisible@61, strikethrough@62, overline@63, underline@64 (i32)) + const STYLE_SIZE = 72; + const u32Ptr = this.exports.ghostty_wasm_alloc_u8_array(4); + const rgbPtr = this.exports.ghostty_wasm_alloc_u8_array(3); + const dirtyPtr = this.exports.ghostty_wasm_alloc_u8(); + const rawPtr = this.exports.ghostty_wasm_alloc_u8_array(8); + const wrapPtr = this.exports.ghostty_wasm_alloc_u8(); + const stylePtr = this.exports.ghostty_wasm_alloc_u8_array(STYLE_SIZE); + new DataView(this.memory.buffer).setUint32(stylePtr, STYLE_SIZE, true); + // Per-cell RAW + WIDE scratch. Cells are 8 bytes (u64); the WIDE + // enum is a 4-byte int. + const cellRawPtr = this.exports.ghostty_wasm_alloc_u8_array(8); + const widePtr = this.exports.ghostty_wasm_alloc_u8_array(4); + // Populate the row meta caches as a side effect — saves a redundant + // iterator walk if the renderer also calls isRowDirty() / isRowWrapped() + // on this snapshot. + const dirtyCache = new Array(this._rows).fill(false); + const wrapCache = new Array(this._rows).fill(false); + try { + let row = 0; + while ( + row < this._rows && + this.exports.ghostty_render_state_row_iterator_next(this.rowIter) + ) { + // Capture per-row dirty + wrap for the caches. + this.exports.ghostty_render_state_row_get(this.rowIter, RenderStateRowData.DIRTY, dirtyPtr); + dirtyCache[row] = new DataView(this.memory.buffer).getUint8(dirtyPtr) !== 0; + + this.exports.ghostty_render_state_row_get(this.rowIter, RenderStateRowData.RAW, rawPtr); + const rowU64 = new DataView(this.memory.buffer).getBigUint64(rawPtr, true); + this.exports.ghostty_row_get(rowU64, RowData.WRAP_CONTINUATION, wrapPtr); + wrapCache[row] = new DataView(this.memory.buffer).getUint8(wrapPtr) !== 0; + + // Bind rowCells to this row. + this.populateHandle( + (out) => + this.exports.ghostty_render_state_row_get(this.rowIter, RenderStateRowData.CELLS, out), + this.rowCells + ); - // Parse cells into pool (reuses existing objects) - this.parseCellsIntoPool(this.viewportBufferPtr, totalCells); + let col = 0; + while ( + col < this._cols && + this.exports.ghostty_render_state_row_cells_next(this.rowCells) + ) { + const cell = this.cellPool[row * this._cols + col]!; + + // Grapheme length. Upstream includes the base codepoint: + // empty cell -> 0 + // simple ASCII 'a' -> 1 (just 'a') + // ZWJ family emoji -> N (base + N-1 combining) + // Coder's cell.grapheme_len counts only the "extras" beyond the + // base, so we subtract one (clamped at 0). The full count is + // available to callers that want it through getGrapheme(). + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.GRAPHEMES_LEN, + u32Ptr + ); + const memView = new DataView(this.memory.buffer); + const graphemeLen = memView.getUint32(u32Ptr, true); + cell.grapheme_len = graphemeLen > 0 ? graphemeLen - 1 : 0; + + if (graphemeLen > 0) { + // GRAPHEMES_BUF writes graphemeLen u32 codepoints. We only need + // the base codepoint here; multi-codepoint clusters go through + // getGrapheme() separately. + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.GRAPHEMES_BUF, + u32Ptr + ); + cell.codepoint = new DataView(this.memory.buffer).getUint32(u32Ptr, true); + } else { + cell.codepoint = 0; + } + + // Resolved fg/bg. Returns INVALID_VALUE (non-zero) when the cell + // has no explicit color; mark fg/bgIsDefault so the renderer + // applies the theme default rather than rendering literal black + // (the rgb triple stays zeroed but is meaningless when isDefault). + cell.fg_r = cell.fg_g = cell.fg_b = 0; + cell.bg_r = cell.bg_g = cell.bg_b = 0; + cell.fgIsDefault = true; + cell.bgIsDefault = true; + if ( + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.FG_COLOR, + rgbPtr + ) === 0 + ) { + const u8 = new Uint8Array(this.memory.buffer, rgbPtr, 3); + cell.fg_r = u8[0]!; + cell.fg_g = u8[1]!; + cell.fg_b = u8[2]!; + cell.fgIsDefault = false; + } + if ( + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.BG_COLOR, + rgbPtr + ) === 0 + ) { + const u8 = new Uint8Array(this.memory.buffer, rgbPtr, 3); + cell.bg_r = u8[0]!; + cell.bg_g = u8[1]!; + cell.bg_b = u8[2]!; + cell.bgIsDefault = false; + } + + // Read the per-cell style and pack the booleans into the flags + // bitmask coder's renderer / Buffer API consumes. The function + // always returns a valid style (default for unstyled cells). + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.STYLE, + stylePtr + ); + { + const u8 = new Uint8Array(this.memory.buffer, stylePtr, STYLE_SIZE); + let f = 0; + if (u8[56]) f |= CellFlags.BOLD; + if (u8[57]) f |= CellFlags.ITALIC; + if (u8[58]) f |= CellFlags.FAINT; + if (u8[59]) f |= CellFlags.BLINK; + if (u8[60]) f |= CellFlags.INVERSE; + if (u8[61]) f |= CellFlags.INVISIBLE; + if (u8[62]) f |= CellFlags.STRIKETHROUGH; + // u8[63] is `overline` — coder's CellFlags doesn't model it. + // Underline at offset 64 is an i32 enum (NONE/SINGLE/DOUBLE/ + // CURLY/DOTTED/DASHED); collapse any non-zero to a single flag. + if (new DataView(this.memory.buffer).getInt32(stylePtr + 64, true) !== 0) { + f |= CellFlags.UNDERLINE; + } + cell.flags = f; + } + + // Read the raw cell value once, then use it to query per-cell + // properties not exposed at the row_cells level. Width matters + // for CJK / wide emoji — without it the renderer skips the + // spacer cells correctly only if the wide cell itself has + // width=2, otherwise glyphs overlap or the spacer cell paints + // an empty box. + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.RAW, + cellRawPtr + ); + const cellU64 = new DataView(this.memory.buffer).getBigUint64(cellRawPtr, true); + this.exports.ghostty_cell_get(cellU64, CellData.WIDE, widePtr); + const wide = new DataView(this.memory.buffer).getUint32(widePtr, true); + cell.width = + wide === CellWide.WIDE + ? 2 + : wide === CellWide.SPACER_TAIL || wide === CellWide.SPACER_HEAD + ? 0 + : 1; + + // OSC 8 hyperlink presence. Coder's old packed cell struct + // exposed this as effectively a 0/1 boolean (despite the + // u16-sized field) — the renderer compares + // hyperlink_id === hoveredId to mean "this cell is part of + // some hyperlink, same as the hovered one" rather than + // "the *same* hyperlink instance," with link-detector + // identifying actual links via URI + position range. We + // preserve that contract here. + this.exports.ghostty_cell_get(cellU64, CellData.HAS_HYPERLINK, widePtr); + cell.hyperlink_id = new DataView(this.memory.buffer).getUint8(widePtr) !== 0 ? 1 : 0; + + col++; + } + row++; + } + } finally { + this.exports.ghostty_wasm_free_u8_array(u32Ptr, 4); + this.exports.ghostty_wasm_free_u8_array(rgbPtr, 3); + this.exports.ghostty_wasm_free_u8(dirtyPtr); + this.exports.ghostty_wasm_free_u8_array(rawPtr, 8); + this.exports.ghostty_wasm_free_u8(wrapPtr); + this.exports.ghostty_wasm_free_u8_array(stylePtr, STYLE_SIZE); + this.exports.ghostty_wasm_free_u8_array(cellRawPtr, 8); + this.exports.ghostty_wasm_free_u8_array(widePtr, 4); + } + + this.rowDirtyCache = dirtyCache; + this.rowWrapCache = wrapCache; return this.cellPool; } + /** + * Helper for the in/out pointer pattern used by ROW_ITERATOR / ROW_DATA_CELLS: + * write a handle into a 4-byte slot, hand the slot to a populator, then + * free the slot. The handle value itself is unchanged; the populator uses + * it to find and rebind the iterator's internal data. + */ + private populateHandle(populator: (slotPtr: number) => number, handle: number): void { + const slot = this.exports.ghostty_wasm_alloc_u8_array(4); + new DataView(this.memory.buffer).setUint32(slot, handle, true); + populator(slot); + this.exports.ghostty_wasm_free_u8_array(slot, 4); + } + + /** + * Reset every cell in the pool to "empty" so cells we don't visit during + * iteration (e.g. iterator stopped early, or grid resized down) don't + * carry stale values from a previous frame. + */ + private zeroCellPool(): void { + for (let i = 0; i < this.cellPool.length; i++) { + const cell = this.cellPool[i]!; + cell.codepoint = 0; + cell.fg_r = cell.fg_g = cell.fg_b = 0; + cell.bg_r = cell.bg_g = cell.bg_b = 0; + cell.fgIsDefault = true; + cell.bgIsDefault = true; + cell.flags = 0; + cell.width = 1; + cell.hyperlink_id = 0; + cell.grapheme_len = 0; + } + } + // ========================================================================== // Compatibility methods (delegate to render state) // ========================================================================== @@ -513,7 +1423,8 @@ export class GhosttyTerminal { // ========================================================================== isAlternateScreen(): boolean { - return !!this.exports.ghostty_terminal_is_alternate_screen(this.handle); + // ACTIVE_SCREEN returns a GhosttyTerminalScreen enum (4-byte int). + return this.tGetU32(TerminalData.ACTIVE_SCREEN) === TerminalScreen.ALTERNATE; } hasBracketedPaste(): boolean { @@ -527,7 +1438,7 @@ export class GhosttyTerminal { } hasMouseTracking(): boolean { - return this.exports.ghostty_terminal_has_mouse_tracking(this.handle) !== 0; + return this.tGetU8(TerminalData.MOUSE_TRACKING) !== 0; } // ========================================================================== @@ -541,205 +1452,457 @@ export class GhosttyTerminal { /** Get number of scrollback lines (history, not including active screen) */ getScrollbackLength(): number { - return this.exports.ghostty_terminal_get_scrollback_length(this.handle); + // SCROLLBACK_ROWS is size_t — 4 bytes on wasm32. + return this.tGetU32(TerminalData.SCROLLBACK_ROWS); } /** * Get a line from the scrollback buffer. - * Ensures render state is fresh by calling update(). - * @param offset 0 = oldest line, (length-1) = most recent scrollback line + * @param offset 0 = oldest scrollback line, (scrollbackLength-1) = most + * recent scrollback line. + * + * Uses ghostty_terminal_grid_ref with POINT_TAG_HISTORY to address rows + * outside the active viewport. The render-state row iterator only walks + * the viewport, so scrollback access has to go through grid_ref. + * + * Cell content is currently codepoint-only; fg/bg colors, style flags, + * and hyperlinks are deferred (defaults: 0 colors, flags=0, width=1). + * The text-extraction tests that drove this commit only check codepoints. */ getScrollbackLine(offset: number): GhosttyCell[] | null { - const neededSize = this._cols * GhosttyTerminal.CELL_SIZE; - - // Ensure buffer is allocated - if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { - if (this.viewportBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); - } - this.viewportBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(neededSize); - this.viewportBufferSize = neededSize; - } - - // Call update() to ensure render state is fresh (needed for colors). - // This is safe to call multiple times - dirty state persists until markClean(). - this.update(); - - const count = this.exports.ghostty_terminal_get_scrollback_line( - this.handle, - offset, - this.viewportBufferPtr, - this._cols - ); - - if (count < 0) return null; - - // Parse cells - const cells: GhosttyCell[] = []; - const buffer = this.memory.buffer; - const u8 = new Uint8Array(buffer, this.viewportBufferPtr, count * GhosttyTerminal.CELL_SIZE); - const view = new DataView(buffer, this.viewportBufferPtr, count * GhosttyTerminal.CELL_SIZE); - - for (let i = 0; i < count; i++) { - const cellOffset = i * GhosttyTerminal.CELL_SIZE; - cells.push({ - codepoint: view.getUint32(cellOffset, true), - fg_r: u8[cellOffset + 4], - fg_g: u8[cellOffset + 5], - fg_b: u8[cellOffset + 6], - bg_r: u8[cellOffset + 7], - bg_g: u8[cellOffset + 8], - bg_b: u8[cellOffset + 9], - flags: u8[cellOffset + 10], - width: u8[cellOffset + 11], - hyperlink_id: view.getUint16(cellOffset + 12, true), - grapheme_len: u8[cellOffset + 14], - }); - } - - return cells; + return this.readGridLine(PointTag.HISTORY, offset); } - /** Check if a row in the active screen is wrapped (soft-wrapped to next line) */ - isRowWrapped(row: number): boolean { - return this.exports.ghostty_terminal_is_row_wrapped(this.handle, row) !== 0; + /** + * Get the hyperlink URI for a cell at the given position in the active + * viewport. Returns null when no hyperlink is attached. + */ + getHyperlinkUri(row: number, col: number): string | null { + if (row < 0 || row >= this._rows) return null; + if (col < 0 || col >= this._cols) return null; + return this.readHyperlinkUri(PointTag.ACTIVE, row, col); } /** - * Get the hyperlink URI for a cell at the given position. - * @param row Row index (0-based, in active viewport) - * @param col Column index (0-based) - * @returns The URI string, or null if no hyperlink at that position + * Get the hyperlink URI for a cell in the scrollback buffer. */ - getHyperlinkUri(row: number, col: number): string | null { - // Check if WASM has this function (requires rebuilt WASM with hyperlink support) - if (!this.exports.ghostty_terminal_get_hyperlink_uri) { - return null; - } + getScrollbackHyperlinkUri(offset: number, col: number): string | null { + if (col < 0 || col >= this._cols) return null; + return this.readHyperlinkUri(PointTag.HISTORY, offset, col); + } - // Try with initial buffer, retry with larger if needed (for very long URLs) - const bufferSizes = [2048, 8192, 32768]; + // ========================================================================== + // grid_ref helpers + // + // GhosttyPoint : 24 bytes (tag@0:u32, value@8:union 16 bytes). + // The union's first member is GhosttyPointCoordinate + // (x@0:u16, y@4:u32). + // GhosttyGridRef: 12 bytes — sized struct (size@0:u32, node@4:opaque, + // x@8:u16, y@10:u16). x/y are public so we can step + // along a row by mutating ref.x in place rather than + // re-resolving the point per cell. + // + // A grid ref is invalidated by ANY terminal mutation. The whole helper + // body must run between vt_writes — read everything we need, copy out, + // free. + // ========================================================================== - for (const bufSize of bufferSizes) { - const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); + private readGridLine(tag: PointTag, y: number): GhosttyCell[] | null { + const pointPtr = this.allocPoint(tag, 0, y); + const refPtr = this.exports.ghostty_wasm_alloc_u8_array(12); + new DataView(this.memory.buffer).setUint32(refPtr, 12, true); // size field + try { + if (this.exports.ghostty_terminal_grid_ref(this.handle, pointPtr, refPtr) !== 0) { + return null; + } + // Pre-fetch the terminal's effective palette (256 RGB triples = + // 768 bytes) so we can resolve PALETTE-tagged style colors per + // cell without a round-trip per resolution. Cells with style + // colors of tag NONE leave fg_r/g/b at 0; the renderer's + // isDefaultFg path treats that as "use theme default." + const PAL_SIZE = 768; + const palettePtr = this.exports.ghostty_wasm_alloc_u8_array(PAL_SIZE); + const palOk = + this.exports.ghostty_terminal_get(this.handle, TerminalData.COLOR_PALETTE, palettePtr) === + 0; + const palette = palOk + ? new Uint8Array(this.memory.buffer, palettePtr, PAL_SIZE).slice() + : null; + + const cells: GhosttyCell[] = new Array(this._cols); + const cellPtr = this.exports.ghostty_wasm_alloc_u8_array(8); + const u32Ptr = this.exports.ghostty_wasm_alloc_u8_array(4); + const widePtr = this.exports.ghostty_wasm_alloc_u8_array(4); + // Style is the 72-byte GhosttyStyle sized struct. Initialize the + // size discriminator once; the populator overwrites the rest. + const STYLE_SIZE = 72; + const stylePtr = this.exports.ghostty_wasm_alloc_u8_array(STYLE_SIZE); + new DataView(this.memory.buffer).setUint32(stylePtr, STYLE_SIZE, true); try { - const bytesWritten = this.exports.ghostty_terminal_get_hyperlink_uri( - this.handle, - row, - col, - bufPtr, - bufSize - ); - - // 0 means no hyperlink at this position - if (bytesWritten === 0) return null; - - // -1 means buffer too small, try next size - if (bytesWritten === -1) continue; - - // Negative values other than -1 are errors - if (bytesWritten < 0) return null; - - const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesWritten); - return new TextDecoder().decode(bytes.slice()); + for (let col = 0; col < this._cols; col++) { + // Step along the row by mutating ref.x in place. + new DataView(this.memory.buffer).setUint16(refPtr + 8, col, true); + if (this.exports.ghostty_grid_ref_cell(refPtr, cellPtr) !== 0) { + cells[col] = this.makeEmptyCell(); + continue; + } + const memView = new DataView(this.memory.buffer); + const cellU64 = memView.getBigUint64(cellPtr, true); + + // Codepoint. + this.exports.ghostty_cell_get(cellU64, CellData.CODEPOINT, u32Ptr); + const cp = new DataView(this.memory.buffer).getUint32(u32Ptr, true); + + // Width: same NARROW/WIDE/SPACER mapping as getViewport. + this.exports.ghostty_cell_get(cellU64, CellData.WIDE, widePtr); + const wide = new DataView(this.memory.buffer).getUint32(widePtr, true); + const width = + wide === CellWide.WIDE + ? 2 + : wide === CellWide.SPACER_TAIL || wide === CellWide.SPACER_HEAD + ? 0 + : 1; + + // Hyperlink presence as 0/1 — same approximation getViewport + // uses (link-detector identifies actual links by URI + + // position range; the renderer just needs the indicator). + this.exports.ghostty_cell_get(cellU64, CellData.HAS_HYPERLINK, widePtr); + const hasHyperlink = new DataView(this.memory.buffer).getUint8(widePtr) !== 0; + + // Style: per-position via grid_ref_style (not via cell — + // styles aren't stored in the cell value, they're attached + // to the row's pin position). + new DataView(this.memory.buffer).setUint32(stylePtr, STYLE_SIZE, true); + const styleOk = this.exports.ghostty_grid_ref_style(refPtr, stylePtr) === 0; + + const cell = this.makeEmptyCell(); + cell.codepoint = cp; + cell.width = width; + cell.hyperlink_id = hasHyperlink ? 1 : 0; + + if (styleOk) { + const u8 = new Uint8Array(this.memory.buffer, stylePtr, STYLE_SIZE); + const v = new DataView(this.memory.buffer); + // Flag bytes 56..63; underline (i32) at 64. + let f = 0; + if (u8[56]) f |= CellFlags.BOLD; + if (u8[57]) f |= CellFlags.ITALIC; + if (u8[58]) f |= CellFlags.FAINT; + if (u8[59]) f |= CellFlags.BLINK; + if (u8[60]) f |= CellFlags.INVERSE; + if (u8[61]) f |= CellFlags.INVISIBLE; + if (u8[62]) f |= CellFlags.STRIKETHROUGH; + if (v.getInt32(stylePtr + 64, true) !== 0) f |= CellFlags.UNDERLINE; + cell.flags = f; + + // fg_color at offset 8, bg_color at offset 24. + // Each is 16 bytes: tag@0:u32, padding to 8, value@8:union. + // Value union: palette index at first byte; or rgb (r,g,b) + // in first 3 bytes; or u64 padding for ABI stability. + this.resolveStyleColor(stylePtr + 8, palette, cell, /*isFg=*/ true); + this.resolveStyleColor(stylePtr + 24, palette, cell, /*isFg=*/ false); + } + + cells[col] = cell; + } } finally { - this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); + this.exports.ghostty_wasm_free_u8_array(cellPtr, 8); + this.exports.ghostty_wasm_free_u8_array(u32Ptr, 4); + this.exports.ghostty_wasm_free_u8_array(widePtr, 4); + this.exports.ghostty_wasm_free_u8_array(stylePtr, STYLE_SIZE); + this.exports.ghostty_wasm_free_u8_array(palettePtr, PAL_SIZE); } + return cells; + } finally { + this.exports.ghostty_wasm_free_u8_array(pointPtr, 24); + this.exports.ghostty_wasm_free_u8_array(refPtr, 12); } - - // URI too long even for largest buffer - return null; } /** - * Get the hyperlink URI for a cell in the scrollback buffer. - * @param offset Scrollback line offset (0 = oldest, scrollback_len-1 = newest) - * @param col Column index (0-based) - * @returns The URI string, or null if no hyperlink at that position + * Decode a GhosttyStyleColor (16 bytes at colorPtr — tag@0:u32, + * value@8:union) and write the resolved RGB into the cell's fg_* + * or bg_* triple. Tag values: NONE=0 (leaves zeros so the renderer's + * theme fallback kicks in), PALETTE=1 (looks up the terminal's + * effective palette), RGB=2 (direct read). */ - getScrollbackHyperlinkUri(offset: number, col: number): string | null { - // Check if WASM has this function - if (!this.exports.ghostty_terminal_get_scrollback_hyperlink_uri) { - return null; + private resolveStyleColor( + colorPtr: number, + palette: Uint8Array | null, + cell: GhosttyCell, + isFg: boolean + ): void { + const view = new DataView(this.memory.buffer); + const tag = view.getUint32(colorPtr + 0, true); + let r = 0; + let g = 0; + let b = 0; + // tag === 0 (NONE): no explicit color — the cell uses the terminal's + // default fg/bg. PALETTE / RGB are explicit; record the resolved RGB. + const isDefault = tag === 0; + if (tag === 1 /* PALETTE */ && palette) { + const idx = view.getUint8(colorPtr + 8); + r = palette[idx * 3 + 0]!; + g = palette[idx * 3 + 1]!; + b = palette[idx * 3 + 2]!; + } else if (tag === 2 /* RGB */) { + r = view.getUint8(colorPtr + 8); + g = view.getUint8(colorPtr + 9); + b = view.getUint8(colorPtr + 10); } + if (isFg) { + cell.fg_r = r; + cell.fg_g = g; + cell.fg_b = b; + cell.fgIsDefault = isDefault; + } else { + cell.bg_r = r; + cell.bg_g = g; + cell.bg_b = b; + cell.bgIsDefault = isDefault; + } + } - // Try with initial buffer, retry with larger if needed (for very long URLs) - const bufferSizes = [2048, 8192, 32768]; - - for (const bufSize of bufferSizes) { - const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); - + private readHyperlinkUri(tag: PointTag, y: number, col: number): string | null { + const pointPtr = this.allocPoint(tag, col, y); + const refPtr = this.exports.ghostty_wasm_alloc_u8_array(12); + new DataView(this.memory.buffer).setUint32(refPtr, 12, true); + try { + if (this.exports.ghostty_terminal_grid_ref(this.handle, pointPtr, refPtr) !== 0) { + return null; + } + // Two-pass read: first call with len=0 to get required size, then + // allocate exactly. Most cells have no hyperlink — we get out_len=0 + // on the first call and skip the second alloc entirely. + const outLenPtr = this.exports.ghostty_wasm_alloc_usize(); try { - const bytesWritten = this.exports.ghostty_terminal_get_scrollback_hyperlink_uri( - this.handle, - offset, - col, - bufPtr, - bufSize - ); - - // 0 means no hyperlink at this position - if (bytesWritten === 0) return null; - - // -1 means buffer too small, try next size - if (bytesWritten === -1) continue; - - // Negative values other than -1 are errors - if (bytesWritten < 0) return null; - - const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesWritten); - return new TextDecoder().decode(bytes.slice()); + // First pass: pass NULL buf (0) and len=0; out_len gets populated. + // ghostty_grid_ref_hyperlink_uri returns OUT_OF_SPACE when there + // is data; SUCCESS with out_len=0 when there is none. + this.exports.ghostty_grid_ref_hyperlink_uri(refPtr, 0, 0, outLenPtr); + const needed = new DataView(this.memory.buffer).getUint32(outLenPtr, true); + if (needed === 0) return null; + + const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(needed); + try { + const r = this.exports.ghostty_grid_ref_hyperlink_uri(refPtr, bufPtr, needed, outLenPtr); + if (r !== 0) return null; + const written = new DataView(this.memory.buffer).getUint32(outLenPtr, true); + const bytes = new Uint8Array(this.memory.buffer, bufPtr, written); + return new TextDecoder().decode(bytes.slice()); + } finally { + this.exports.ghostty_wasm_free_u8_array(bufPtr, needed); + } } finally { - this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); + this.exports.ghostty_wasm_free_usize(outLenPtr); } + } finally { + this.exports.ghostty_wasm_free_u8_array(pointPtr, 24); + this.exports.ghostty_wasm_free_u8_array(refPtr, 12); } + } + + private allocPoint(tag: PointTag, x: number, y: number): number { + // GhosttyPoint = { tag: u32 @ 0, padding: 4, value.coordinate: { x: u16 @ 0, y: u32 @ 4 } @ 8 } + const ptr = this.exports.ghostty_wasm_alloc_u8_array(24); + const view = new DataView(this.memory.buffer); + // Zero the padding bytes too, since we don't want stale memory in the union. + new Uint8Array(this.memory.buffer, ptr, 24).fill(0); + view.setUint32(ptr + 0, tag, true); + view.setUint16(ptr + 8, x, true); + view.setUint32(ptr + 12, y, true); + return ptr; + } - // URI too long even for largest buffer - return null; + private makeEmptyCell(): GhosttyCell { + return { + codepoint: 0, + fg_r: 0, + fg_g: 0, + fg_b: 0, + bg_r: 0, + bg_g: 0, + bg_b: 0, + fgIsDefault: true, + bgIsDefault: true, + flags: 0, + width: 1, + hyperlink_id: 0, + grapheme_len: 0, + }; } /** - * Check if there are pending responses from the terminal. - * Responses are generated by escape sequences like DSR (Device Status Report). + * Whether any terminal response bytes are queued for readResponse(). + * + * Responses are delivered synchronously during vt_write() by the + * WRITE_PTY callback (e.g. DSR replies, XTVERSION, in-band size reports). + * They sit in pendingResponses until drained. */ hasResponse(): boolean { - return this.exports.ghostty_terminal_has_response(this.handle); + return this.pendingResponses.length > 0; } /** - * Read pending responses from the terminal. - * Returns the response string, or null if no responses pending. - * - * Responses are generated by escape sequences that require replies: - * - DSR 6 (cursor position): Returns \x1b[row;colR - * - DSR 5 (operating status): Returns \x1b[0n + * Drain queued response bytes, decode as UTF-8, return as a single + * string. Multiple callback invocations are concatenated. Returns null + * when nothing's pending so the demo's echo loop can short-circuit. */ readResponse(): string | null { - if (!this.hasResponse()) return null; - - const bufSize = 256; // Most responses are small - const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); + if (this.pendingResponses.length === 0) return null; + let total = 0; + for (const chunk of this.pendingResponses) total += chunk.length; + const merged = new Uint8Array(total); + let offset = 0; + for (const chunk of this.pendingResponses) { + merged.set(chunk, offset); + offset += chunk.length; + } + this.pendingResponses.length = 0; + return new TextDecoder().decode(merged); + } - try { - const bytesRead = this.exports.ghostty_terminal_read_response(this.handle, bufPtr, bufSize); + /** + * Install the WRITE_PTY and SIZE trampoline callbacks. + * + * Trampolines are shared across all terminals that come from the + * same WASM instance, but NOT across instances — terminal handles are + * only unique within their parent module, and table indices in module + * A are meaningless in module B's table. So we keep a per-table + * registry (WeakMap keyed on the indirect function table) that owns + * the slot indices plus the handle→instance routing map for that + * table. + * + * On first use for a given table we instantiate the trampolines, + * `table.grow(2)`, and write both into the new slots. Subsequent + * terminals from the same module reuse the registry and just + * register their handle in instancesByHandle. + */ + private installCallbacks(): void { + const table = (this.exports as unknown as { __indirect_function_table: WebAssembly.Table }) + .__indirect_function_table; + + let registry = GhosttyTerminal.callbackRegistries.get(table); + if (!registry) { + const instancesByHandle = new Map(); + const writePtyDispatch: WritePtyCallback = (handle, _userdata, dataPtr, dataLen) => { + const term = instancesByHandle.get(handle); + if (!term) return; + // Copy out — the underlying WASM memory may be mutated or + // detached by the next allocation, and the chunk lives until + // readResponse drains it. + term.pendingResponses.push(new Uint8Array(term.memory.buffer, dataPtr, dataLen).slice()); + }; + const sizeDispatch: SizeCallback = (handle, _userdata, outSizePtr) => { + const term = instancesByHandle.get(handle); + if (!term) return 0; + // Without real cell pixel dims the response would be nonsense; + // returning false (0) tells the terminal to silently drop the + // size query, matching coder's old behavior for unconfigured + // pixel sizes. + if (term.cellWidthPx === 0 || term.cellHeightPx === 0) return 0; + // GhosttySizeReportSize: rows@0:u16, cols@2:u16, cell_w@4:u32, + // cell_h@8:u32 (12 bytes total). + const view = new DataView(term.memory.buffer); + view.setUint16(outSizePtr + 0, term._rows, true); + view.setUint16(outSizePtr + 2, term._cols, true); + view.setUint32(outSizePtr + 4, term.cellWidthPx, true); + view.setUint32(outSizePtr + 8, term.cellHeightPx, true); + return 1; + }; + // PNG decoder dispatcher. Called by ghostty when it needs to + // decode a kitty graphics PNG payload (kitten icat sends these by + // default — won't work without a decoder installed). Synchronous; + // we lean on fast-png for sync decode since createImageBitmap is + // async and unavailable from a sync C callback. + // + // Inputs: an allocator pointer (the library's, so the buffer we + // hand back gets freed on the same heap), PNG bytes in WASM + // memory, and a 16-byte out struct to fill. + // Out layout (GhosttySysImage): u32 width @ 0, u32 height @ 4, + // u32 data_ptr @ 8, u32 data_len @ 12. + const exports = this.exports; + const memory = this.memory; + const decodePngDispatch: DecodePngCallback = ( + _userdata, + allocator, + dataPtr, + dataLen, + outImagePtr + ) => { + try { + const pngBytes = new Uint8Array(memory.buffer, dataPtr, dataLen).slice(); + const img = decodePng(pngBytes); + // fast-png returns 8/16-bit per channel data and various + // channel counts (plus an optional palette for indexed PNGs). + // The library expects RGBA u8. Normalize. + const rgba = pngToRgba8(img); + if (!rgba) return 0; + const outBuf = exports.ghostty_alloc(allocator, rgba.length); + if (outBuf === 0) return 0; + new Uint8Array(memory.buffer, outBuf, rgba.length).set(rgba); + const view = new DataView(memory.buffer); + view.setUint32(outImagePtr + 0, img.width, true); + view.setUint32(outImagePtr + 4, img.height, true); + view.setUint32(outImagePtr + 8, outBuf, true); + view.setUint32(outImagePtr + 12, rgba.length, true); + return 1; + } catch { + return 0; + } + }; + + const { writePtyFwd, sizeFwd, decodePngFwd } = makeCallbackTrampolines( + writePtyDispatch, + sizeDispatch, + decodePngDispatch + ); + // Grow once per slot, write each. + const writePtyIndex = table.grow(1); + table.set(writePtyIndex, writePtyFwd); + const sizeIndex = table.grow(1); + table.set(sizeIndex, sizeFwd); + const decodePngIndex = table.grow(1); + table.set(decodePngIndex, decodePngFwd); + registry = { writePtyIndex, sizeIndex, decodePngIndex, instancesByHandle }; + GhosttyTerminal.callbackRegistries.set(table, registry); + + // Install PNG decoder system-wide for this WASM instance. sys_set + // is process/instance-global (not per-terminal) so we do it + // exactly once per __indirect_function_table — same lifetime as + // the trampoline registry itself. + this.exports.ghostty_sys_set(SysOption.DECODE_PNG, decodePngIndex); + } - if (bytesRead <= 0) return null; + // Register `this` so the dispatchers (both close over + // instancesByHandle) can route to the right instance. + registry.instancesByHandle.set(this.handle, this); + this.callbackRegistry = registry; - const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesRead); - return new TextDecoder().decode(bytes.slice()); - } finally { - this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); - } + // The third arg to _set is the value — for callbacks ("pointer + // types"), the value IS the function pointer, i.e. the table index + // we just installed, passed directly. + this.exports.ghostty_terminal_set( + this.handle, + TerminalOption.WRITE_PTY, + registry.writePtyIndex + ); + this.exports.ghostty_terminal_set(this.handle, TerminalOption.SIZE, registry.sizeIndex); } /** - * Query arbitrary terminal mode by number + * Query arbitrary terminal mode by number. * @param mode Mode number (e.g., 25 for cursor visibility, 2004 for bracketed paste) * @param isAnsi True for ANSI modes, false for DEC modes (default: false) */ getMode(mode: number, isAnsi: boolean = false): boolean { - return this.exports.ghostty_terminal_get_mode(this.handle, mode, isAnsi) !== 0; + const packed = packMode(mode, isAnsi); + const out = this.exports.ghostty_wasm_alloc_u8(); + this.exports.ghostty_terminal_mode_get(this.handle, packed, out); + const v = new DataView(this.memory.buffer).getUint8(out); + this.exports.ghostty_wasm_free_u8(out); + return v !== 0; } // ========================================================================== @@ -752,12 +1915,14 @@ export class GhosttyTerminal { for (let i = this.cellPool.length; i < total; i++) { this.cellPool.push({ codepoint: 0, - fg_r: 204, - fg_g: 204, - fg_b: 204, + fg_r: 0, + fg_g: 0, + fg_b: 0, bg_r: 0, bg_g: 0, bg_b: 0, + fgIsDefault: true, + bgIsDefault: true, flags: 0, width: 1, hyperlink_id: 0, @@ -767,32 +1932,6 @@ export class GhosttyTerminal { } } - private parseCellsIntoPool(ptr: number, count: number): void { - const buffer = this.memory.buffer; - const u8 = new Uint8Array(buffer, ptr, count * GhosttyTerminal.CELL_SIZE); - const view = new DataView(buffer, ptr, count * GhosttyTerminal.CELL_SIZE); - - for (let i = 0; i < count; i++) { - const offset = i * GhosttyTerminal.CELL_SIZE; - const cell = this.cellPool[i]; - cell.codepoint = view.getUint32(offset, true); - cell.fg_r = u8[offset + 4]; - cell.fg_g = u8[offset + 5]; - cell.fg_b = u8[offset + 6]; - cell.bg_r = u8[offset + 7]; - cell.bg_g = u8[offset + 8]; - cell.bg_b = u8[offset + 9]; - cell.flags = u8[offset + 10]; - cell.width = u8[offset + 11]; - cell.hyperlink_id = view.getUint16(offset + 12, true); - cell.grapheme_len = u8[offset + 14]; // grapheme_len is at byte 14 - } - } - - /** Small buffer for grapheme lookups (reused to avoid allocation) */ - private graphemeBuffer: Uint32Array | null = null; - private graphemeBufferPtr: number = 0; - /** * Get all codepoints for a grapheme cluster at the given position. * For most cells this returns a single codepoint, but for complex scripts @@ -800,25 +1939,61 @@ export class GhosttyTerminal { * @returns Array of codepoints, or null on error */ getGrapheme(row: number, col: number): number[] | null { - // Allocate buffer on first use (16 codepoints should be enough for any grapheme) - if (!this.graphemeBuffer) { - this.graphemeBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(16 * 4); - this.graphemeBuffer = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, 16); + if (row < 0 || row >= this._rows) return null; + if (col < 0 || col >= this._cols) return null; + + this.update(); + + // Bind iterator to current state and walk forward to the target row. + this.populateHandle( + (out) => + this.exports.ghostty_render_state_get(this.renderHandle, RenderStateData.ROW_ITERATOR, out), + this.rowIter + ); + for (let r = 0; r <= row; r++) { + if (!this.exports.ghostty_render_state_row_iterator_next(this.rowIter)) { + return null; + } } - const count = this.exports.ghostty_render_state_get_grapheme( - this.handle, - row, - col, - this.graphemeBufferPtr, - 16 + // Bind cells from this row, then position at the target column. + this.populateHandle( + (out) => + this.exports.ghostty_render_state_row_get(this.rowIter, RenderStateRowData.CELLS, out), + this.rowCells ); + if (this.exports.ghostty_render_state_row_cells_select(this.rowCells, col) !== 0) { + return null; + } - if (count < 0) return null; + const lenPtr = this.exports.ghostty_wasm_alloc_u8_array(4); + let len = 0; + try { + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.GRAPHEMES_LEN, + lenPtr + ); + len = new DataView(this.memory.buffer).getUint32(lenPtr, true); + } finally { + this.exports.ghostty_wasm_free_u8_array(lenPtr, 4); + } + if (len === 0) return []; - // Re-create view in case memory grew - const view = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, count); - return Array.from(view); + const bufBytes = len * 4; + const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufBytes); + try { + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.GRAPHEMES_BUF, + bufPtr + ); + // Copy out before freeing — the array reference shares the WASM memory + // buffer and a subsequent allocation could detach it. + return Array.from(new Uint32Array(this.memory.buffer, bufPtr, len)); + } finally { + this.exports.ghostty_wasm_free_u8_array(bufPtr, bufBytes); + } } /** @@ -838,25 +2013,40 @@ export class GhosttyTerminal { * @returns Array of codepoints, or null on error */ getScrollbackGrapheme(offset: number, col: number): number[] | null { - // Reuse the same buffer as getGrapheme - if (!this.graphemeBuffer) { - this.graphemeBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(16 * 4); - this.graphemeBuffer = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, 16); - } - - const count = this.exports.ghostty_terminal_get_scrollback_grapheme( - this.handle, - offset, - col, - this.graphemeBufferPtr, - 16 - ); + if (col < 0 || col >= this._cols) return null; - if (count < 0) return null; - - // Re-create view in case memory grew - const view = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, count); - return Array.from(view); + const pointPtr = this.allocPoint(PointTag.HISTORY, col, offset); + const refPtr = this.exports.ghostty_wasm_alloc_u8_array(12); + new DataView(this.memory.buffer).setUint32(refPtr, 12, true); + try { + if (this.exports.ghostty_terminal_grid_ref(this.handle, pointPtr, refPtr) !== 0) { + return null; + } + // Same two-pass pattern as readHyperlinkUri: query length first, then + // allocate the exact codepoint buffer. + const outLenPtr = this.exports.ghostty_wasm_alloc_usize(); + try { + this.exports.ghostty_grid_ref_graphemes(refPtr, 0, 0, outLenPtr); + const needed = new DataView(this.memory.buffer).getUint32(outLenPtr, true); + if (needed === 0) return []; + + const bytes = needed * 4; // codepoints are u32 + const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bytes); + try { + const r = this.exports.ghostty_grid_ref_graphemes(refPtr, bufPtr, needed, outLenPtr); + if (r !== 0) return null; + const written = new DataView(this.memory.buffer).getUint32(outLenPtr, true); + return Array.from(new Uint32Array(this.memory.buffer, bufPtr, written)); + } finally { + this.exports.ghostty_wasm_free_u8_array(bufPtr, bytes); + } + } finally { + this.exports.ghostty_wasm_free_usize(outLenPtr); + } + } finally { + this.exports.ghostty_wasm_free_u8_array(pointPtr, 24); + this.exports.ghostty_wasm_free_u8_array(refPtr, 12); + } } /** @@ -867,17 +2057,108 @@ export class GhosttyTerminal { if (!codepoints || codepoints.length === 0) return ' '; return String.fromCodePoint(...codepoints); } +} - private invalidateBuffers(): void { - if (this.viewportBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); - this.viewportBufferPtr = 0; - this.viewportBufferSize = 0; - } - if (this.graphemeBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.graphemeBufferPtr, 16 * 4); - this.graphemeBufferPtr = 0; +/** + * Normalize a fast-png decode result into a tightly packed 8-bit RGBA + * buffer (4 bytes/pixel). fast-png returns whichever channel count and + * bit depth the source PNG used (1/8/16-bit; 1/2/3/4 channels); + * libghostty wants u8 RGBA. + * + * Returns null on any unexpected shape. + */ +function pngToRgba8(img: { + width: number; + height: number; + channels: number; + depth: number; + // fast-png types this as PngDataArray (Uint8Array | Uint8ClampedArray | + // Uint16Array). All three index numerically — we just need to handle + // depth 8 vs 16 since 1/2/4-bit PNGs come back already expanded to 8. + data: ArrayLike; + /** For indexed (palette) PNGs: array of [r,g,b] triples; data values + * are 1-byte indices into this array. Absent for non-indexed PNGs. */ + palette?: number[][]; + /** Per-index alpha for tRNS in indexed PNGs (each value 0-255 in the + * low byte regardless of bit depth). Indices past this array's + * length are fully opaque. */ + transparency?: ArrayLike; +}): Uint8Array | null { + const { width, height, channels, depth, data, palette, transparency } = img; + const px = width * height; + const out = new Uint8Array(px * 4); + + // Indexed (palette) PNG. fast-png reports channels=1 with the palette + // separate; if we just blitted `data` we'd get black-and-white because + // palette indices look like dim grayscale values. Apply the palette + // and per-index alpha here. + // + // Alpha source order — fast-png is inconsistent across PNG layouts: + // 1. palette[idx][3] — fast-png folds tRNS-derived alpha into the + // palette tuples themselves for many indexed-with-transparency + // PNGs (its `IndexedColors` type is documented as RGB triples + // but the runtime values are RGBA quadruples). + // 2. transparency[idx] — when fast-png does surface tRNS as its + // own field instead of folding into palette entries. + // 3. 255 fallback — fully opaque. + if (palette && palette.length > 0) { + for (let i = 0, o = 0; i < px; i++, o += 4) { + const idx = data[i]! ?? 0; + const rgb = palette[idx] ?? palette[0]!; + out[o] = rgb[0]!; + out[o + 1] = rgb[1]!; + out[o + 2] = rgb[2]!; + out[o + 3] = + rgb.length >= 4 + ? rgb[3]! + : transparency && idx < transparency.length + ? transparency[idx]! + : 255; } - this.graphemeBuffer = null; + return out; + } + + // Bring 16-bit channels down to 8 by dropping the low byte. + const get = (i: number): number => { + if (depth === 16) return data[i]! >> 8; + return data[i]! ?? 0; + }; + switch (channels) { + case 4: + for (let i = 0, o = 0; i < px * 4; i += 4, o += 4) { + out[o] = get(i); + out[o + 1] = get(i + 1); + out[o + 2] = get(i + 2); + out[o + 3] = get(i + 3); + } + return out; + case 3: + for (let i = 0, o = 0; i < px * 3; i += 3, o += 4) { + out[o] = get(i); + out[o + 1] = get(i + 1); + out[o + 2] = get(i + 2); + out[o + 3] = 255; + } + return out; + case 2: + for (let i = 0, o = 0; i < px * 2; i += 2, o += 4) { + const v = get(i); + out[o] = v; + out[o + 1] = v; + out[o + 2] = v; + out[o + 3] = get(i + 1); + } + return out; + case 1: + for (let i = 0, o = 0; i < px; i++, o += 4) { + const v = get(i); + out[o] = v; + out[o + 1] = v; + out[o + 2] = v; + out[o + 3] = 255; + } + return out; + default: + return null; } } diff --git a/lib/kitty_diacritics.ts b/lib/kitty_diacritics.ts new file mode 100644 index 0000000..442be54 --- /dev/null +++ b/lib/kitty_diacritics.ts @@ -0,0 +1,60 @@ +/** + * Combining diacritics used by the kitty graphics protocol to encode + * row / column positions inside Unicode placeholder cells. + * + * Each diacritic codepoint here represents an integer equal to its + * 0-based index in this list. So U+0305 = 0, U+030D = 1, U+030E = 2, + * and so on through 296. A placeholder cell stacks combining marks on + * U+10EEEE; the first encodes the row index, the second encodes the + * column index, and an optional third encodes the high byte of the + * image id (since the foreground color only carries 24 bits and image + * ids can be 32 bits wide). + * + * Source-of-truth: kovidgoyal/kitty:gen/rowcolumn-diacritics.txt + * (Unicode 6.0.0 combining chars of class 230 that don't precompose; + * see kitty's docs for the full derivation rationale). + */ +export const ROWCOLUMN_DIACRITICS: readonly number[] = [ + 0x0305, 0x030d, 0x030e, 0x0310, 0x0312, 0x033d, 0x033e, 0x033f, 0x0346, 0x034a, 0x034b, 0x034c, + 0x0350, 0x0351, 0x0352, 0x0357, 0x035b, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369, + 0x036a, 0x036b, 0x036c, 0x036d, 0x036e, 0x036f, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, + 0x0593, 0x0594, 0x0595, 0x0597, 0x0598, 0x0599, 0x059c, 0x059d, 0x059e, 0x059f, 0x05a0, 0x05a1, + 0x05a8, 0x05a9, 0x05ab, 0x05ac, 0x05af, 0x05c4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615, + 0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065a, 0x065b, 0x065d, 0x065e, 0x06d6, 0x06d7, 0x06d8, + 0x06d9, 0x06da, 0x06db, 0x06dc, 0x06df, 0x06e0, 0x06e1, 0x06e2, 0x06e4, 0x06e7, 0x06e8, 0x06eb, + 0x06ec, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073a, 0x073d, 0x073f, 0x0740, 0x0741, 0x0743, + 0x0745, 0x0747, 0x0749, 0x074a, 0x07eb, 0x07ec, 0x07ed, 0x07ee, 0x07ef, 0x07f0, 0x07f1, 0x07f3, + 0x0816, 0x0817, 0x0818, 0x0819, 0x081b, 0x081c, 0x081d, 0x081e, 0x081f, 0x0820, 0x0821, 0x0822, + 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082a, 0x082b, 0x082c, 0x082d, 0x0951, 0x0953, 0x0954, + 0x0f82, 0x0f83, 0x0f86, 0x0f87, 0x135d, 0x135e, 0x135f, 0x17dd, 0x193a, 0x1a17, 0x1a75, 0x1a76, + 0x1a77, 0x1a78, 0x1a79, 0x1a7a, 0x1a7b, 0x1a7c, 0x1b6b, 0x1b6d, 0x1b6e, 0x1b6f, 0x1b70, 0x1b71, + 0x1b72, 0x1b73, 0x1cd0, 0x1cd1, 0x1cd2, 0x1cda, 0x1cdb, 0x1ce0, 0x1dc0, 0x1dc1, 0x1dc3, 0x1dc4, + 0x1dc5, 0x1dc6, 0x1dc7, 0x1dc8, 0x1dc9, 0x1dcb, 0x1dcc, 0x1dd1, 0x1dd2, 0x1dd3, 0x1dd4, 0x1dd5, + 0x1dd6, 0x1dd7, 0x1dd8, 0x1dd9, 0x1dda, 0x1ddb, 0x1ddc, 0x1ddd, 0x1dde, 0x1ddf, 0x1de0, 0x1de1, + 0x1de2, 0x1de3, 0x1de4, 0x1de5, 0x1de6, 0x1dfe, 0x20d0, 0x20d1, 0x20d4, 0x20d5, 0x20d6, 0x20d7, + 0x20db, 0x20dc, 0x20e1, 0x20e7, 0x20e9, 0x20f0, 0x2cef, 0x2cf0, 0x2cf1, 0x2de0, 0x2de1, 0x2de2, + 0x2de3, 0x2de4, 0x2de5, 0x2de6, 0x2de7, 0x2de8, 0x2de9, 0x2dea, 0x2deb, 0x2dec, 0x2ded, 0x2dee, + 0x2def, 0x2df0, 0x2df1, 0x2df2, 0x2df3, 0x2df4, 0x2df5, 0x2df6, 0x2df7, 0x2df8, 0x2df9, 0x2dfa, + 0x2dfb, 0x2dfc, 0x2dfd, 0x2dfe, 0x2dff, 0xa66f, 0xa67c, 0xa67d, 0xa6f0, 0xa6f1, 0xa8e0, 0xa8e1, + 0xa8e2, 0xa8e3, 0xa8e4, 0xa8e5, 0xa8e6, 0xa8e7, 0xa8e8, 0xa8e9, 0xa8ea, 0xa8eb, 0xa8ec, 0xa8ed, + 0xa8ee, 0xa8ef, 0xa8f0, 0xa8f1, 0xaab0, 0xaab2, 0xaab3, 0xaab7, 0xaab8, 0xaabe, 0xaabf, 0xaac1, + 0xfe20, 0xfe21, 0xfe22, 0xfe23, 0xfe24, 0xfe25, 0xfe26, 0x10a0f, 0x10a38, 0x1d185, 0x1d186, + 0x1d187, 0x1d188, 0x1d189, 0x1d1aa, 0x1d1ab, 0x1d1ac, 0x1d1ad, 0x1d242, 0x1d243, 0x1d244, +]; + +/** + * Reverse lookup: codepoint → integer index. Built once at module load. + * Returns -1 for codepoints that aren't valid kitty diacritics. + */ +const DIACRITIC_INDEX = new Map(ROWCOLUMN_DIACRITICS.map((cp, i) => [cp, i])); + +export function diacriticToInt(cp: number): number { + return DIACRITIC_INDEX.get(cp) ?? -1; +} + +/** + * Unicode codepoint for the kitty graphics placeholder cell. + * Cells with this codepoint are substituted with an image slice at + * render time rather than rendered as text. + */ +export const KITTY_PLACEHOLDER = 0x10eeee; diff --git a/lib/renderer.ts b/lib/renderer.ts index 3b51bfd..676a45d 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -11,9 +11,10 @@ */ import type { ITheme } from './interfaces'; +import { KITTY_PLACEHOLDER, diacriticToInt } from './kitty_diacritics'; import type { SelectionManager } from './selection-manager'; -import type { GhosttyCell, ILink } from './types'; -import { CellFlags } from './types'; +import type { GhosttyCell, ILink, KittyImagePixels, KittyPlacementInfo } from './types'; +import { CellFlags, KittyImageFormat } from './types'; // Interface for objects that can be rendered export interface IRenderable { @@ -30,6 +31,20 @@ export interface IRenderable { * For simple cells, returns the single character. */ getGraphemeString?(row: number, col: number): string; + + // Kitty graphics — optional. When implemented, the renderer composites + // images onto the canvas after text rendering. GhosttyTerminal provides + // these; other IRenderable implementations (e.g. test fakes) can omit. + getKittyGraphics?(): number | null; + iterPlacements?(graphics: number, onlyVisible?: boolean): Iterable; + getKittyImagePixels?(graphics: number, imageId: number): KittyImagePixels | null; + /** + * Returns the full codepoint sequence for the cell at (row, col) in + * the active screen — the base codepoint followed by any combining + * marks. Used to decode unicode-placeholder cells (U+10EEEE plus + * combining diacritics that encode row/column slice positions). + */ + getGrapheme?(row: number, col: number): number[] | null; } export interface IScrollbackProvider { @@ -91,6 +106,32 @@ export const DEFAULT_THEME: Required = { // CanvasRenderer Class // ============================================================================ +/** + * Staleness check for kittyImageCache: an entry is reusable iff every + * identity field matches the just-fetched KittyImagePixels. Width/height/ + * format catch geometry/format changes (which can keep dataLen identical — + * e.g., 100×50 RGBA and 50×100 RGBA both serialize to 20000 bytes), and + * dataPtr (the WASM byteOffset) catches re-allocations from retransmits. + */ +function cachedMatchesPixels( + cached: { + width: number; + height: number; + format: KittyImageFormat; + dataPtr: number; + dataLen: number; + }, + pixels: KittyImagePixels +): boolean { + return ( + cached.width === pixels.width && + cached.height === pixels.height && + cached.format === pixels.format && + cached.dataPtr === pixels.data.byteOffset && + cached.dataLen === pixels.data.length + ); +} + export class CanvasRenderer { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; @@ -108,12 +149,99 @@ export class CanvasRenderer { private cursorBlinkInterval?: number; private lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 }; + // Hook called whenever the renderer's own internal state (today: cursor + // blink toggle) changes such that the next frame would look different. + // Set by Terminal so it can wake its render scheduler. Without this, an + // event-driven Terminal that has gone idle would never repaint the + // blinking cursor. + private onRequestRender: (() => void) | null = null; + // Viewport tracking (for scrolling) private lastViewportY: number = 0; // Current buffer being rendered (for grapheme lookups) private currentBuffer: IRenderable | null = null; + /** + * Decoded kitty graphics images, keyed by image id. Each entry caches + * a canvas painted from the WASM-side RGBA bytes so per-frame compositing + * is just a drawImage call. + * + * Staleness key combines width/height/format/dataPtr/dataLen — the + * kitty protocol allows reusing an id with new bytes, and dataLen alone + * is too weak (transposed dims or format change can keep byte count + * identical). dataPtr is the WASM byteOffset, which changes whenever + * ghostty frees + re-allocates the image bytes (i.e., on retransmit). + */ + private kittyImageCache = new Map< + number, + { + canvas: HTMLCanvasElement; + width: number; + height: number; + format: KittyImageFormat; + dataPtr: number; + dataLen: number; + } + >(); + + /** + * Per-frame index of virtual placements keyed by image id. Populated + * once at the start of each render() pass (cheap — typically zero or + * a handful of entries). Looked up by U+10EEEE placeholder cells in + * renderPlaceholderCell to find the placement's grid dimensions. + */ + private kittyVirtualPlacements = new Map(); + + /** + * Direct (non-virtual) placements that need compositing this frame. + * Built once per render() in precomputeKittyState so renderKittyImages + * doesn't re-walk the iterator. Empty when no kitty graphics are active. + */ + private currentDirectPlacements: KittyPlacementInfo[] = []; + + /** + * Last frame's direct-placement signatures, keyed by image id. Used to + * detect placement add/remove/move/redecode so we can mark the affected + * rows for repaint (clearing stale image pixels) and skip the composite + * pass entirely when nothing has changed. dataLen is the same staleness + * discriminator used by kittyImageCache. + */ + private lastKittyDirectSigs = new Map< + number, + { + viewportCol: number; + viewportRow: number; + pixelWidth: number; + pixelHeight: number; + sourceX: number; + sourceY: number; + sourceWidth: number; + sourceHeight: number; + imgWidth: number; + imgHeight: number; + imgFormat: KittyImageFormat; + dataPtr: number; + dataLen: number; + } + >(); + + /** + * Rows whose image footprint changed since last frame (placement added, + * removed, moved, resized, or re-decoded under the same id). Added to + * rowsToRender so the underlying text repaints — which clears stale + * image pixels — before we composite the current placements on top. + */ + private kittyDamagedRows = new Set(); + + /** + * Cached IRenderable on the current render() call so renderCellText + * can call into it (e.g. getGrapheme) without us threading the buffer + * through every helper. Set at the top of render(), cleared at the end. + */ + private currentRenderBuffer: IRenderable | null = null; + private currentKittyGraphics: number | null = null; + // Selection manager (for rendering selection) private selectionManager?: SelectionManager; // Cached selection coordinates for current render pass (viewport-relative) @@ -273,11 +401,20 @@ export class CanvasRenderer { ): void { // Store buffer reference for grapheme lookups in renderCell this.currentBuffer = buffer; + this.currentRenderBuffer = buffer; // getCursor() calls update() internally to ensure fresh state. // Multiple update() calls are safe - dirty state persists until clearDirty(). const cursor = buffer.getCursor(); const dims = buffer.getDimensions(); + + // Pre-frame: build the virtual-placement index so unicode-placeholder + // cells can look up their target image's grid layout in O(1) during + // the per-cell text pass. Also collects direct placements + computes + // kittyDamagedRows (rows where a placement was added/removed/moved/ + // re-decoded, so the text underneath needs repainting to clear stale + // image pixels). + this.precomputeKittyState(buffer, dims.rows); const scrollbackLength = scrollbackProvider ? scrollbackProvider.getScrollbackLength() : 0; // Check if buffer needs full redraw (e.g., screen change between normal/alternate) @@ -431,7 +568,11 @@ export class CanvasRenderer { const needsRender = viewportY > 0 ? true - : forceAll || buffer.isRowDirty(y) || selectionRows.has(y) || hyperlinkRows.has(y); + : forceAll || + buffer.isRowDirty(y) || + selectionRows.has(y) || + hyperlinkRows.has(y) || + this.kittyDamagedRows.has(y); if (needsRender) { rowsToRender.add(y); @@ -483,6 +624,22 @@ export class CanvasRenderer { // Link underlines are drawn during cell rendering (see renderCell) + // Composite kitty graphics images on top of the text. MVP z-order is + // "above text" — programs sending images typically clear the cell area + // first, so there's nothing meaningful underneath. A future commit can + // split into below/above-text passes via PlacementLayer if real apps + // need it. + // + // Skip when no rows were repainted: the previous frame's image pixels + // are still on the canvas and unchanged, and re-issuing drawImage with + // source-over compositing onto translucent images would accumulate + // alpha. Placement adds/removes/moves seed kittyDamagedRows in + // precomputeKittyState, which forces those rows into rowsToRender and + // flips anyLinesRendered to true. + if (this.currentDirectPlacements.length > 0 && anyLinesRendered) { + this.renderKittyImages(); + } + // Render cursor (only if we're at the bottom, not scrolled) if (viewportY === 0 && cursor.visible && this.cursorVisible) { this.renderCursor(cursor.x, cursor.y); @@ -576,10 +733,15 @@ export class CanvasRenderer { bg_b = cell.fg_b; } - // Only draw cell background if it's different from the default (black) - // This lets the theme background (drawn earlier) show through for default cells - const isDefaultBg = bg_r === 0 && bg_g === 0 && bg_b === 0; - if (!isDefaultBg) { + // Cells with the default bg let the line-level theme.background fill + // (drawn earlier in renderLine) show through. Cells with an explicit + // bg — including literal RGB(0,0,0) — get painted here. The cell's + // bgIsDefault flag carries the GhosttyStyleColor tag from upstream; + // we cannot infer it from the RGB triple because (0,0,0) is a valid + // explicit color (programs emit it for "true black" backgrounds, e.g. + // letterboxed image renderings). + const useThemeBg = (cell.flags & CellFlags.INVERSE) ? cell.fgIsDefault : cell.bgIsDefault; + if (!useThemeBg) { this.ctx.fillStyle = this.rgbToCSS(bg_r, bg_g, bg_b); this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); } @@ -594,6 +756,16 @@ export class CanvasRenderer { const cellY = y * this.metrics.height; const cellWidth = this.metrics.width * cell.width; + // Kitty unicode placeholder: cells with codepoint U+10EEEE represent + // a slice of a virtually-placed image. Substitute the slice draw for + // text rendering. If it's not a valid placeholder (e.g., the image + // hasn't been transmitted yet), fall through and render as text — + // typically the system "missing glyph" box, which is the expected + // behavior for a stray U+10EEEE. + if (cell.codepoint === KITTY_PLACEHOLDER) { + if (this.renderPlaceholderCell(cell, x, y)) return; + } + // Skip rendering if invisible if (cell.flags & CellFlags.INVISIBLE) { return; @@ -614,19 +786,27 @@ export class CanvasRenderer { } else if (isSelected) { this.ctx.fillStyle = this.theme.selectionForeground; } else { - // Extract colors and handle inverse + // Extract colors and handle inverse. Mirrors the background path + // above: cells with no explicit color come back as (0,0,0) — treat + // that as a sentinel for "use theme default" rather than rendering + // literal black. Without this, default-fg text on a dark theme is + // invisible. let fg_r = cell.fg_r, fg_g = cell.fg_g, fg_b = cell.fg_b; if (cell.flags & CellFlags.INVERSE) { - // When inverted, foreground becomes background + // When inverted, foreground becomes background. fg_r = cell.bg_r; fg_g = cell.bg_g; fg_b = cell.bg_b; } - this.ctx.fillStyle = this.rgbToCSS(fg_r, fg_g, fg_b); + // Same reasoning as the bg path: only fall back to theme.foreground + // when the cell has the default fg (tag NONE), not when its explicit + // RGB happens to be (0,0,0). + const useThemeFg = (cell.flags & CellFlags.INVERSE) ? cell.bgIsDefault : cell.fgIsDefault; + this.ctx.fillStyle = useThemeFg ? this.theme.foreground : this.rgbToCSS(fg_r, fg_g, fg_b); } // Apply faint effect @@ -647,7 +827,21 @@ export class CanvasRenderer { // Simple cell - single codepoint char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null } - this.ctx.fillText(char, textX, textY); + + // Block elements (U+2580..U+259F) draw as fillRect using the same + // fillStyle. The browser's font rasterization of these glyphs leaves + // sub-pixel gaps that line up into a visible cell grid in half-block + // image renderings (ansimage, pixterm) at dpr=1; native terminals + // (Ghostty, kitty, alacritty) draw them programmatically for the same + // reason. Only takes the fast path for simple (non-grapheme) cells. + if ( + cell.grapheme_len === 0 && + this.renderBlockElement(cell.codepoint, cellX, cellY, cellWidth) + ) { + // handled by renderBlockElement + } else { + this.ctx.fillText(char, textX, textY); + } // Reset alpha if (cell.flags & CellFlags.FAINT) { @@ -713,6 +907,414 @@ export class CanvasRenderer { } } + /** + * Composite all visible kitty graphics placements onto the canvas. + * Cheap when no graphics are active (one method check, one terminal_get). + * Decode work is amortized across frames via kittyImageCache. + */ + /** + * Walk the placement iterator once at frame start, partitioning the + * results: virtual placements go into kittyVirtualPlacements (keyed + * by image id) for placeholder-cell lookup; direct visible placements + * stay implicit and get re-iterated by renderKittyImages later. + * + * Also caches the storage handle for renderPlaceholderCell so the + * per-cell hot path doesn't have to re-resolve it. + */ + private precomputeKittyState(buffer: IRenderable, dimsRows: number): void { + this.kittyVirtualPlacements.clear(); + this.currentDirectPlacements = []; + this.kittyDamagedRows.clear(); + this.currentKittyGraphics = null; + + const newSigs: typeof this.lastKittyDirectSigs = new Map(); + const cellH = this.metrics.height; + const markRows = (viewportRow: number, pixelHeight: number): void => { + const rowStart = Math.max(0, Math.floor(viewportRow)); + const rowEnd = Math.min(dimsRows, Math.ceil(viewportRow + pixelHeight / cellH)); + for (let r = rowStart; r < rowEnd; r++) this.kittyDamagedRows.add(r); + }; + + if (buffer.getKittyGraphics && buffer.iterPlacements) { + const graphics = buffer.getKittyGraphics(); + if (graphics !== null) { + this.currentKittyGraphics = graphics; + // onlyVisible=false so virtual placements come through too. We + // partition: virtuals into kittyVirtualPlacements (placeholder-cell + // lookup), directs into currentDirectPlacements (composite pass). + for (const p of buffer.iterPlacements(graphics, false)) { + if (p.isVirtual) { + this.kittyVirtualPlacements.set(p.imageId, p); + continue; + } + this.currentDirectPlacements.push(p); + const pixels = buffer.getKittyImagePixels?.(graphics, p.imageId); + const sig = { + viewportCol: p.viewportCol, + viewportRow: p.viewportRow, + pixelWidth: p.pixelWidth, + pixelHeight: p.pixelHeight, + sourceX: p.sourceX, + sourceY: p.sourceY, + sourceWidth: p.sourceWidth, + sourceHeight: p.sourceHeight, + imgWidth: pixels?.width ?? 0, + imgHeight: pixels?.height ?? 0, + imgFormat: pixels?.format ?? (0 as KittyImageFormat), + dataPtr: pixels?.data.byteOffset ?? 0, + dataLen: pixels?.data.length ?? 0, + }; + newSigs.set(p.imageId, sig); + const prev = this.lastKittyDirectSigs.get(p.imageId); + const changed = + !prev || + prev.viewportCol !== sig.viewportCol || + prev.viewportRow !== sig.viewportRow || + prev.pixelWidth !== sig.pixelWidth || + prev.pixelHeight !== sig.pixelHeight || + prev.sourceX !== sig.sourceX || + prev.sourceY !== sig.sourceY || + prev.sourceWidth !== sig.sourceWidth || + prev.sourceHeight !== sig.sourceHeight || + prev.imgWidth !== sig.imgWidth || + prev.imgHeight !== sig.imgHeight || + prev.imgFormat !== sig.imgFormat || + prev.dataPtr !== sig.dataPtr || + prev.dataLen !== sig.dataLen; + if (changed) { + markRows(sig.viewportRow, sig.pixelHeight); + if (prev) markRows(prev.viewportRow, prev.pixelHeight); + } + } + } + } + + // Removed placements (were drawn last frame, gone now): mark their + // rows so text repaint clears stale image pixels. + for (const [id, prev] of this.lastKittyDirectSigs) { + if (!newSigs.has(id)) markRows(prev.viewportRow, prev.pixelHeight); + } + this.lastKittyDirectSigs = newSigs; + } + + /** + * Get (or decode + cache) the canvas-ready bitmap for a kitty image. + * Returns null if the image isn't stored or decode fails. Shared by + * renderKittyImages (direct placements) and renderPlaceholderCell + * (unicode-placeholder cells). + */ + private getOrDecodeKittyImage( + buffer: IRenderable, + graphics: number, + imageId: number + ): HTMLCanvasElement | null { + const cached = this.kittyImageCache.get(imageId); + const pixels = buffer.getKittyImagePixels?.(graphics, imageId); + if (!pixels) return cached?.canvas ?? null; + if (cached && cachedMatchesPixels(cached, pixels)) return cached.canvas; + const canvas = this.decodeKittyImageToCanvas(pixels); + if (!canvas) return null; + this.kittyImageCache.set(imageId, { + canvas, + width: pixels.width, + height: pixels.height, + format: pixels.format, + dataPtr: pixels.data.byteOffset, + dataLen: pixels.data.length, + }); + return canvas; + } + + /** + * Render a Block Elements codepoint (U+2580..U+259F) as fillRect(s) in + * the current fillStyle. Returns true if the codepoint is a handled + * block element; false to fall through to fillText. + * + * Drawing block elements through the font produces ~1-device-px gaps + * at cell edges at integer dpr because the rasterized glyph doesn't + * exactly fill the cell box. In half-block image renderings (ansimage, + * pixterm) those gaps line up into a visible cell grid. Native + * terminals draw block elements programmatically for the same reason. + * + * The eighths blocks (U+2581..U+2587 lower; U+2589..U+258F left) and + * full block (U+2588) are stripes of n/8 of the cell. Shading blocks + * (U+2591..U+2593) modulate globalAlpha for 25/50/75% fill. Quadrant + * blocks (U+2596..U+259F) split the cell into a 2x2 grid and fill + * some subset. + */ + private renderBlockElement( + codepoint: number, + cellX: number, + cellY: number, + cellWidth: number + ): boolean { + if (codepoint < 0x2580 || codepoint > 0x259f) return false; + + const w = cellWidth; + const h = this.metrics.height; + + // Upper half ▀ + if (codepoint === 0x2580) { + this.ctx.fillRect(cellX, cellY, w, Math.round(h / 2)); + return true; + } + + // Lower n/8 blocks ▁▂▃▄▅▆▇ + full block █ (= 8/8) + if (codepoint >= 0x2581 && codepoint <= 0x2588) { + const eighths = codepoint - 0x2580; + const blockH = Math.round((h * eighths) / 8); + this.ctx.fillRect(cellX, cellY + h - blockH, w, blockH); + return true; + } + + // Left n/8 blocks ▉▊▋▌▍▎▏ — eighths decreases as codepoint increases + if (codepoint >= 0x2589 && codepoint <= 0x258f) { + const eighths = 0x2590 - codepoint; + const blockW = Math.round((w * eighths) / 8); + this.ctx.fillRect(cellX, cellY, blockW, h); + return true; + } + + // Right half ▐ + if (codepoint === 0x2590) { + const left = Math.round(w / 2); + this.ctx.fillRect(cellX + left, cellY, w - left, h); + return true; + } + + // Shading ░▒▓ — modulate globalAlpha against current fillStyle + if (codepoint >= 0x2591 && codepoint <= 0x2593) { + const alphaForShade = [0.25, 0.5, 0.75][codepoint - 0x2591]; + const prev = this.ctx.globalAlpha; + this.ctx.globalAlpha = prev * alphaForShade; + this.ctx.fillRect(cellX, cellY, w, h); + this.ctx.globalAlpha = prev; + return true; + } + + // Upper 1/8 ▔ + if (codepoint === 0x2594) { + this.ctx.fillRect(cellX, cellY, w, Math.round(h / 8)); + return true; + } + + // Right 1/8 ▕ + if (codepoint === 0x2595) { + const left = Math.round((w * 7) / 8); + this.ctx.fillRect(cellX + left, cellY, w - left, h); + return true; + } + + // Quadrants ▖▗▘▙▚▛▜▝▞▟ at U+2596..U+259F. Bitmap of which corners + // (UL, UR, LL, LR) are filled per codepoint. + const QUAD_UL = 0b1000; + const QUAD_UR = 0b0100; + const QUAD_LL = 0b0010; + const QUAD_LR = 0b0001; + const quadMap: Record = { + 0x2596: QUAD_LL, + 0x2597: QUAD_LR, + 0x2598: QUAD_UL, + 0x2599: QUAD_UL | QUAD_LL | QUAD_LR, + 0x259a: QUAD_UL | QUAD_LR, + 0x259b: QUAD_UL | QUAD_UR | QUAD_LL, + 0x259c: QUAD_UL | QUAD_UR | QUAD_LR, + 0x259d: QUAD_UR, + 0x259e: QUAD_UR | QUAD_LL, + 0x259f: QUAD_UR | QUAD_LL | QUAD_LR, + }; + const quads = quadMap[codepoint]; + if (quads === undefined) return false; + const halfW = Math.round(w / 2); + const halfH = Math.round(h / 2); + if (quads & QUAD_UL) this.ctx.fillRect(cellX, cellY, halfW, halfH); + if (quads & QUAD_UR) this.ctx.fillRect(cellX + halfW, cellY, w - halfW, halfH); + if (quads & QUAD_LL) this.ctx.fillRect(cellX, cellY + halfH, halfW, h - halfH); + if (quads & QUAD_LR) + this.ctx.fillRect(cellX + halfW, cellY + halfH, w - halfW, h - halfH); + return true; + } + + /** + * Substitute a cell's text rendering with a slice of a kitty graphics + * image. Called from renderCellText when the cell's codepoint is + * U+10EEEE. + * + * Decodes the image_id from cell.fg_* (low 24 bits; high byte from + * an optional third combining diacritic) and the row/col-of-image + * from the first two combining diacritics on the cell. Looks up the + * virtual placement (from precomputeKittyState) for grid dims, then + * draws the matching slice scaled to one terminal cell. + * + * Returns true if the cell was handled as a placeholder; false to + * fall through to normal text rendering (e.g., unknown image, no + * matching virtual placement, or malformed diacritics). + */ + private renderPlaceholderCell(cell: GhosttyCell, x: number, y: number): boolean { + const buffer = this.currentRenderBuffer; + const graphics = this.currentKittyGraphics; + if (!buffer || graphics === null || !buffer.getGrapheme) return false; + + // Image id from fg color (low 24 bits) + optional 3rd diacritic + // (high byte). The base codepoint at index 0 is U+10EEEE itself; + // [1]=row, [2]=col, [3]=image_id_msb (optional). + const codepoints = buffer.getGrapheme(y, x); + if (!codepoints || codepoints.length < 3) return false; + const rowD = diacriticToInt(codepoints[1]!); + const colD = diacriticToInt(codepoints[2]!); + if (rowD < 0 || colD < 0) return false; + const fgRgb = (cell.fg_r << 16) | (cell.fg_g << 8) | cell.fg_b; + let imageId = fgRgb; + if (codepoints.length >= 4) { + const msb = diacriticToInt(codepoints[3]!); + if (msb >= 0) imageId = (msb << 24) | fgRgb; + } + + const placement = this.kittyVirtualPlacements.get(imageId); + if (!placement) return false; + + const pixels = buffer.getKittyImagePixels?.(graphics, imageId); + if (!pixels) return false; + const canvas = this.getOrDecodeKittyImage(buffer, graphics, imageId); + if (!canvas) return false; + + // Slice geometry: image is conceptually scaled to fit + // gridCols × gridRows cells; this cell shows one of those cells. + const srcW = pixels.width / placement.gridCols; + const srcH = pixels.height / placement.gridRows; + const srcX = colD * srcW; + const srcY = rowD * srcH; + const destX = x * this.metrics.width; + const destY = y * this.metrics.height; + + // Source-rect coords are fractional whenever pixels.{width,height} doesn't + // divide evenly by placement.{gridCols,gridRows}. With smoothing on, each + // slice is sampled with bilinear interpolation clamped to its own source + // rect, producing visible seams between adjacent cells (the classic + // tile-edge artifact). Disable smoothing for the slice draw. + const prevSmoothing = this.ctx.imageSmoothingEnabled; + this.ctx.imageSmoothingEnabled = false; + this.ctx.drawImage( + canvas, + srcX, + srcY, + srcW, + srcH, + destX, + destY, + this.metrics.width, + this.metrics.height + ); + this.ctx.imageSmoothingEnabled = prevSmoothing; + return true; + } + + private renderKittyImages(): void { + const buffer = this.currentRenderBuffer; + const graphics = this.currentKittyGraphics; + if (!buffer || graphics === null || !buffer.getKittyImagePixels) return; + + for (const p of this.currentDirectPlacements) { + let cached = this.kittyImageCache.get(p.imageId); + const pixels = buffer.getKittyImagePixels(graphics, p.imageId); + if (!pixels) continue; + + // Cache miss or stale (image was re-transmitted under the same id). + // See kittyImageCache docstring for staleness-key rationale. + if (!cached || !cachedMatchesPixels(cached, pixels)) { + const canvas = this.decodeKittyImageToCanvas(pixels); + if (!canvas) continue; + cached = { + canvas, + width: pixels.width, + height: pixels.height, + format: pixels.format, + dataPtr: pixels.data.byteOffset, + dataLen: pixels.data.length, + }; + this.kittyImageCache.set(p.imageId, cached); + } + + // Composite. Source/dest rects come straight from the C ABI's + // PlacementRenderInfo; viewport_col/row may be negative when a + // placement has scrolled partway off the top — drawImage handles + // that correctly (clips to the canvas). + this.ctx.drawImage( + cached.canvas, + p.sourceX, + p.sourceY, + p.sourceWidth, + p.sourceHeight, + p.viewportCol * this.metrics.width, + p.viewportRow * this.metrics.height, + p.pixelWidth, + p.pixelHeight + ); + } + } + + /** + * Decode a kitty graphics image into a canvas suitable for drawImage. + * Expands non-RGBA formats into RGBA via putImageData; PNG payloads + * (which require a JS-side decoder set up via ghostty_sys_set) are + * not supported in this MVP and return null. + */ + private decodeKittyImageToCanvas(pixels: KittyImagePixels): HTMLCanvasElement | null { + const { width, height, format, data } = pixels; + if (width === 0 || height === 0) return null; + + // Allocate a fresh ArrayBuffer (not a WASM-memory view) so that + // (a) the bytes survive the next vt_write that might detach the + // WASM memory buffer, and + // (b) ImageData accepts the buffer (it rejects ArrayBufferLike + // which would include SharedArrayBuffer). + const rgba = new Uint8ClampedArray(new ArrayBuffer(width * height * 4)); + switch (format) { + case KittyImageFormat.RGBA: + rgba.set(data); + break; + case KittyImageFormat.RGB: + for (let i = 0, o = 0; i < data.length; i += 3, o += 4) { + rgba[o] = data[i]!; + rgba[o + 1] = data[i + 1]!; + rgba[o + 2] = data[i + 2]!; + rgba[o + 3] = 255; + } + break; + case KittyImageFormat.GRAY: + for (let i = 0, o = 0; i < data.length; i++, o += 4) { + const v = data[i]!; + rgba[o] = v; + rgba[o + 1] = v; + rgba[o + 2] = v; + rgba[o + 3] = 255; + } + break; + case KittyImageFormat.GRAY_ALPHA: + for (let i = 0, o = 0; i < data.length; i += 2, o += 4) { + const v = data[i]!; + rgba[o] = v; + rgba[o + 1] = v; + rgba[o + 2] = v; + rgba[o + 3] = data[i + 1]!; + } + break; + default: + // PNG and unknown formats — skip silently. The terminal would have + // dropped a PNG payload at parse time anyway unless a decoder was + // installed via ghostty_sys_set(DECODE_PNG, fn). + return null; + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + ctx.putImageData(new ImageData(rgba, width, height), 0, 0); + return canvas; + } + /** * Render cursor */ @@ -763,11 +1365,23 @@ export class CanvasRenderer { // Cursor Blinking // ========================================================================== + /** + * Set a callback the renderer invokes when its internal state changes + * outside the normal render-driven path (today: cursor-blink toggles). + * Lets an event-driven Terminal wake its render scheduler instead of + * polling every frame to catch the blink flip. + */ + public setOnRequestRender(fn: (() => void) | null): void { + this.onRequestRender = fn; + } + private startCursorBlink(): void { // xterm.js uses ~530ms blink interval this.cursorBlinkInterval = window.setInterval(() => { this.cursorVisible = !this.cursorVisible; - // Note: Render loop should redraw cursor line automatically + // Wake the render scheduler so the cursor cell is actually + // repainted with the new visibility state. + this.onRequestRender?.(); }, 530); } @@ -949,7 +1563,9 @@ export class CanvasRenderer { * Set the currently hovered hyperlink ID for rendering underlines */ public setHoveredHyperlinkId(hyperlinkId: number): void { + if (this.hoveredHyperlinkId === hyperlinkId) return; this.hoveredHyperlinkId = hyperlinkId; + this.onRequestRender?.(); } /** @@ -964,7 +1580,12 @@ export class CanvasRenderer { endY: number; } | null ): void { + // Coarse change check — link-detection is rate-limited upstream and + // these setters are only called on hover transitions, so identity + // comparison is enough to dedupe back-to-back clears. + if (this.hoveredLinkRange === range) return; this.hoveredLinkRange = range; + this.onRequestRender?.(); } /** diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index d56011e..adf98cf 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -2990,3 +2990,74 @@ describe('Synchronous open()', () => { term.dispose(); }); }); + +// ===================================================================== +// WRITE_PTY callback routing +// +// The new C ABI delivers terminal-generated bytes (DSR replies, in-band +// size reports, XTVERSION, ...) via a callback installed with +// ghostty_terminal_set(WRITE_PTY, fn). The TS wrapper buffers them into +// a per-instance pendingResponses queue drained by readResponse(). +// +// These tests cover the routing — single instance, and two parallel +// Ghostty.load() instances. The latter is a regression caught in code +// review: handle IDs and table indices are only unique within their +// parent WASM module, so a process-wide registry corrupts the routing +// once you have two live instances. +// ===================================================================== +describe('Write PTY response routing', () => { + test('DSR 6 (cursor position) round-trips through readResponse', async () => { + const { Ghostty } = await import('./ghostty'); + const g = await Ghostty.load(); + const t = g.createTerminal(80, 24); + + expect(t.hasResponse()).toBe(false); + t.write('\x1b[6n'); // DSR 6 → cursor position report + expect(t.hasResponse()).toBe(true); + expect(t.readResponse()).toBe('\x1b[1;1R'); + expect(t.hasResponse()).toBe(false); + expect(t.readResponse()).toBe(null); + + t.free(); + }); + + test('XTWINOPS size queries (CSI 14/16/18 t) round-trip after setCellPixelSize', async () => { + const { Ghostty } = await import('./ghostty'); + const g = await Ghostty.load(); + const t = g.createTerminal(80, 24); + + // Without pixel dims set, the SIZE callback returns false and the + // terminal silently drops the query. + t.write('\x1b[14t'); + expect(t.readResponse()).toBe(null); + + t.setCellPixelSize(8, 16); + t.write('\x1b[14t'); // text area in pixels — \e[4;;t + expect(t.readResponse()).toBe('\x1b[4;384;640t'); + t.write('\x1b[16t'); // cell in pixels — \e[6;;t + expect(t.readResponse()).toBe('\x1b[6;16;8t'); + t.write('\x1b[18t'); // rows / cols — \e[8;;t + expect(t.readResponse()).toBe('\x1b[8;24;80t'); + + t.free(); + }); + + test('two parallel Ghostty.load() instances each route to themselves', async () => { + const { Ghostty } = await import('./ghostty'); + // Each load() owns its own __indirect_function_table; the registry + // is keyed off that table so the trampoline slots and routing maps + // don't collide. + const a = await Ghostty.load(); + const b = await Ghostty.load(); + const ta = a.createTerminal(80, 24); + const tb = b.createTerminal(80, 24); + + ta.write('\x1b[6n'); + tb.write('\x1b[6n'); + expect(ta.readResponse()).toBe('\x1b[1;1R'); + expect(tb.readResponse()).toBe('\x1b[1;1R'); + + ta.free(); + tb.free(); + }); +}); diff --git a/lib/terminal.ts b/lib/terminal.ts index eeb7acd..d913de3 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -250,6 +250,10 @@ export class Terminal implements ITerminalCore { this.canvas.style.width = `${metrics.width * this.cols}px`; this.canvas.style.height = `${metrics.height * this.rows}px`; + // Push the new per-cell pixel size into the WASM terminal so size + // reports / kitty graphics see the updated metrics. + this.updateWasmPixelSize(); + // Force full re-render with new font this.renderer.render(this.wasmTerm, true, this.viewportY, this); } @@ -428,6 +432,10 @@ export class Terminal implements ITerminalCore { // Size canvas to terminal dimensions (use renderer.resize for proper DPI scaling) this.renderer.resize(this.cols, this.rows); + // Push initial cell pixel dims into the WASM terminal — needed for + // size reports and kitty graphics from the very first vt_write. + this.updateWasmPixelSize(); + // Create mouse tracking configuration const canvas = this.canvas; const renderer = this.renderer; @@ -494,6 +502,8 @@ export class Terminal implements ITerminalCore { // Forward selection change events this.selectionManager.onSelectionChange(() => { this.selectionChangeEmitter.fire(); + // Selection rows need to repaint with the highlight overlay. + this.requestRender(); }); // Initialize link detection system @@ -522,8 +532,16 @@ export class Terminal implements ITerminalCore { // Render initial blank screen (force full redraw) this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity); - // Start render loop - this.startRenderLoop(); + // Wire the renderer back to the render scheduler so internal + // state changes (cursor blink) wake the loop on demand. + this.renderer.setOnRequestRender(() => this.requestRender()); + + // Run one synchronous render+cursor-poll to mirror the prior + // loop's first iteration. Some downstream callers + // (notably refreshRowMetaCache for isRowWrapped) walk the WASM + // row iterator immediately after open() and rely on the second + // update() / clearDirty pair to settle WASM state. + this.renderTick(); // Focus input (auto-focus so user can start typing immediately) this.focus(); @@ -592,7 +610,9 @@ export class Terminal implements ITerminalCore { requestAnimationFrame(callback); } - // Render will happen on next animation frame + // Wake the render scheduler — the write almost certainly mutated + // visible state. Idempotent if a render is already pending. + this.requestRender(); } /** @@ -689,6 +709,11 @@ export class Terminal implements ITerminalCore { this.canvas!.style.width = `${metrics.width * cols}px`; this.canvas!.style.height = `${metrics.height * rows}px`; + // Refresh WASM cell pixel dims after the resize. Cell metrics + // typically don't change on a logical resize, but this handles + // DPR changes and is cheap (no-ops when values are unchanged). + this.updateWasmPixelSize(); + // Fire resize event this.resizeEmitter.fire({ cols, rows }); @@ -698,9 +723,10 @@ export class Terminal implements ITerminalCore { console.error('Terminal resize failed:', e); } - // Flush any writes that were queued during resize, then restart render loop + // Flush any writes that were queued during resize, then schedule a + // render to pick up the new dimensions / flushed writes. this.flushWriteQueue(); - this.startRenderLoop(); + this.requestRender(); } /** @@ -725,6 +751,11 @@ export class Terminal implements ITerminalCore { const config = this.buildWasmConfig(); this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); + // The fresh WASM terminal starts with zero cell pixel dims, so CSI + // 14/16/18 t and kitty graphics sizing would silently report zeros + // until a font/resize event re-pushed them. Reapply now. + this.updateWasmPixelSize(); + // Clear renderer this.renderer!.clear(); @@ -921,6 +952,8 @@ export class Terminal implements ITerminalCore { if (scrollbackLength > 0) { this.showScrollbar(); } + + this.requestRender(); } } @@ -941,6 +974,7 @@ export class Terminal implements ITerminalCore { this.viewportY = scrollbackLength; this.scrollEmitter.fire(this.viewportY); this.showScrollbar(); + this.requestRender(); } } @@ -955,6 +989,7 @@ export class Terminal implements ITerminalCore { if (this.getScrollbackLength() > 0) { this.showScrollbar(); } + this.requestRender(); } } @@ -974,6 +1009,8 @@ export class Terminal implements ITerminalCore { if (scrollbackLength > 0) { this.showScrollbar(); } + + this.requestRender(); } } @@ -1000,6 +1037,7 @@ export class Terminal implements ITerminalCore { if (scrollbackLength > 0) { this.showScrollbar(); } + this.requestRender(); return; } @@ -1048,6 +1086,8 @@ export class Terminal implements ITerminalCore { this.scrollAnimationFrame = undefined; this.scrollAnimationStartTime = undefined; this.scrollAnimationStartY = undefined; + // Final-position render + this.requestRender(); return; } @@ -1068,6 +1108,11 @@ export class Terminal implements ITerminalCore { this.showScrollbar(); } + // Each tick mutates viewportY, so the main render path needs to + // catch up. The animateScroll rAF below only advances the scroll + // state; rendering is the renderTick's job. + this.requestRender(); + // Continue animation this.scrollAnimationFrame = requestAnimationFrame(this.animateScroll); }; @@ -1129,6 +1174,23 @@ export class Terminal implements ITerminalCore { // Private Methods // ========================================================================== + /** + * Push the renderer's per-cell pixel size into the WASM terminal. + * + * Called from setup, open(), and resize() — everywhere the renderer + * may have rebuilt its FontMetrics. Affects in-band size reports + * (CSI 14/16/18 t) and kitty graphics placement sizing; without it + * the terminal returns zeros for those queries. + * + * GhosttyTerminal.setCellPixelSize short-circuits when the values + * haven't changed, so this is cheap to call from any of the above. + */ + private updateWasmPixelSize(): void { + if (!this.renderer || !this.wasmTerm) return; + const metrics = this.renderer.getMetrics(); + this.wasmTerm.setCellPixelSize(metrics.width, metrics.height); + } + /** * Cancel the render loop */ @@ -1150,36 +1212,57 @@ export class Terminal implements ITerminalCore { } /** - * Start the render loop + * Schedule a single render on the next animation frame. No-op if one + * is already pending or the terminal is closed/disposed. + * + * Replaces the previous perpetual rAF chain, which kept a CPU core + * hot at ~60Hz even on a static screen because every frame paid for a + * render() entry/exit and a getCursor() round-trip into WASM. With + * this design, the terminal goes idle (zero JS work, zero WASM calls) + * once the last event-driven render is done, until the next event + * wakes it via requestRender(). + * + * Wake points are added on every event source that mutates renderable + * state: writes from the PTY, scrolls, resizes, mouse motion (link + * hover), selection changes, the cursor-blink interval (via the + * renderer's onRequestRender callback), and each smooth-scroll tick. + * + * Alternative design we considered: leave the rAF chain in place but + * have it short-circuit when no work is pending and self-cancel after + * N idle frames, with the same wake points re-arming it. End-state + * CPU is identical; the difference is purely code shape (a perpetual + * loop with self-cancel logic vs. ad-hoc rAF scheduling). We picked + * this shape for simplicity. */ - private startRenderLoop(): void { - if (this.animationFrameId) return; // already running - const loop = () => { - if (!this.isDisposed && this.isOpen) { - // Render using WASM's native dirty tracking - // The render() method: - // 1. Calls update() once to sync state and check dirty flags - // 2. Only redraws dirty rows when forceAll=false - // 3. Always calls clearDirty() at the end - this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity); - - // Check for cursor movement (Phase 2: onCursorMove event) - // Note: getCursor() reads from already-updated render state (from render() above) - const cursor = this.wasmTerm!.getCursor(); - if (cursor.y !== this.lastCursorY) { - this.lastCursorY = cursor.y; - this.cursorMoveEmitter.fire(); - } + private requestRender(): void { + if (this.animationFrameId !== undefined) return; + if (this.isDisposed || !this.isOpen) return; + this.animationFrameId = requestAnimationFrame(this.renderTick); + } - // Note: onRender event is intentionally not fired in the render loop - // to avoid performance issues. For now, consumers can use requestAnimationFrame - // if they need frame-by-frame updates. + private renderTick = (): void => { + this.animationFrameId = undefined; + if (this.isDisposed || !this.isOpen) return; - this.animationFrameId = requestAnimationFrame(loop); - } - }; - loop(); - } + // Render using WASM's native dirty tracking + // The render() method: + // 1. Calls update() once to sync state and check dirty flags + // 2. Only redraws dirty rows when forceAll=false + // 3. Always calls clearDirty() at the end + this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity); + + // Check for cursor movement (Phase 2: onCursorMove event) + // Note: getCursor() reads from already-updated render state (from render() above) + const cursor = this.wasmTerm!.getCursor(); + if (cursor.y !== this.lastCursorY) { + this.lastCursorY = cursor.y; + this.cursorMoveEmitter.fire(); + } + + // Note: onRender event is intentionally not fired here to avoid + // performance issues. Consumers can use requestAnimationFrame if + // they need frame-by-frame updates. + }; /** * Get a line from native WASM scrollback buffer diff --git a/lib/types.ts b/lib/types.ts index 4d6eefa..e8dd909 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -408,77 +408,186 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { ghostty_key_event_set_utf8(event: number, ptr: number, len: number): void; // Terminal lifecycle - ghostty_terminal_new(cols: number, rows: number): TerminalHandle; - ghostty_terminal_new_with_config(cols: number, rows: number, configPtr: number): TerminalHandle; + ghostty_terminal_new(allocatorPtr: number, terminalPtrPtr: number, optionsPtr: number): number; // GhosttyResult (0 = success) ghostty_terminal_free(terminal: TerminalHandle): void; - ghostty_terminal_resize(terminal: TerminalHandle, cols: number, rows: number): void; - ghostty_terminal_write(terminal: TerminalHandle, dataPtr: number, dataLen: number): void; - - // RenderState API - high-performance rendering (ONE call gets ALL data) - ghostty_render_state_update(terminal: TerminalHandle): number; // 0=none, 1=partial, 2=full - ghostty_render_state_get_cols(terminal: TerminalHandle): number; - ghostty_render_state_get_rows(terminal: TerminalHandle): number; - ghostty_render_state_get_cursor_x(terminal: TerminalHandle): number; - ghostty_render_state_get_cursor_y(terminal: TerminalHandle): number; - ghostty_render_state_get_cursor_visible(terminal: TerminalHandle): boolean; - ghostty_render_state_get_bg_color(terminal: TerminalHandle): number; // 0xRRGGBB - ghostty_render_state_get_fg_color(terminal: TerminalHandle): number; // 0xRRGGBB - ghostty_render_state_is_row_dirty(terminal: TerminalHandle, row: number): boolean; - ghostty_render_state_mark_clean(terminal: TerminalHandle): void; - ghostty_render_state_get_viewport( + ghostty_terminal_resize( terminal: TerminalHandle, + cols: number, + rows: number, + cellWidthPx: number, + cellHeightPx: number + ): number; + ghostty_terminal_vt_write(terminal: TerminalHandle, dataPtr: number, dataLen: number): void; + + // RenderState API — render state is a separate object created from a terminal. + // Read fields via the generic _get(state, key, *out) interface keyed by + // GhosttyRenderStateData; see RenderStateData enum. + ghostty_render_state_new(allocatorPtr: number, statePtrPtr: number): number; + ghostty_render_state_free(state: number): void; + ghostty_render_state_update(state: number, terminal: TerminalHandle): number; + ghostty_render_state_get(state: number, key: number, outPtr: number): number; + ghostty_render_state_get_multi( + state: number, + count: number, + keysPtr: number, + valuesPtr: number, + outWrittenPtr: number + ): number; + ghostty_render_state_set(state: number, option: number, valuePtr: number): number; + ghostty_render_state_colors_get(state: number, outColorsPtr: number): number; + // Row iterator: pre-allocated once, repopulated from the render state via + // ghostty_render_state_get(state, ROW_ITERATOR, &iter). + ghostty_render_state_row_iterator_new(allocatorPtr: number, outIterPtrPtr: number): number; + ghostty_render_state_row_iterator_free(iter: number): void; + ghostty_render_state_row_iterator_next(iter: number): boolean; + ghostty_render_state_row_get(iter: number, key: number, outPtr: number): number; + ghostty_render_state_row_set(iter: number, option: number, valuePtr: number): number; + // Row cells iterator: per-row, populated from a row via + // ghostty_render_state_row_get(iter, ROW_DATA_CELLS, &cells). + ghostty_render_state_row_cells_new(allocatorPtr: number, outCellsPtrPtr: number): number; + ghostty_render_state_row_cells_free(cells: number): void; + ghostty_render_state_row_cells_next(cells: number): boolean; + ghostty_render_state_row_cells_select(cells: number, col: number): number; + ghostty_render_state_row_cells_get(cells: number, key: number, outPtr: number): number; + ghostty_render_state_row_cells_get_multi( + cells: number, + count: number, + keysPtr: number, + valuesPtr: number, + outWrittenPtr: number + ): number; + // Per-cell direct access. GhosttyCell is a u64 — passed as bigint in JS. + ghostty_cell_get(cell: bigint, key: number, outPtr: number): number; + // Per-row direct access. GhosttyRow is a u64 — passed as bigint in JS. + ghostty_row_get(row: bigint, key: number, outPtr: number): number; + // Grid references: read cells / rows / graphemes / hyperlinks at a + // specific GhosttyPoint. Useful for off-viewport (scrollback / history) + // access where the render-state row iterator doesn't reach. + // Note: refs are invalidated by ANY terminal mutation — read and copy out + // before the next vt_write. + ghostty_terminal_grid_ref(terminal: TerminalHandle, pointPtr: number, outRefPtr: number): number; + ghostty_grid_ref_cell(refPtr: number, outCellPtr: number): number; + ghostty_grid_ref_row(refPtr: number, outRowPtr: number): number; + ghostty_grid_ref_graphemes( + refPtr: number, bufPtr: number, - bufLen: number - ): number; // Returns total cells written or -1 on error - ghostty_render_state_get_grapheme( - terminal: TerminalHandle, - row: number, - col: number, + bufLen: number, + outLenPtr: number + ): number; + ghostty_grid_ref_hyperlink_uri( + refPtr: number, bufPtr: number, - bufLen: number - ): number; // Returns count of codepoints or -1 on error - - // Terminal modes - ghostty_terminal_is_alternate_screen(terminal: TerminalHandle): boolean; - ghostty_terminal_has_mouse_tracking(terminal: TerminalHandle): number; - ghostty_terminal_get_mode(terminal: TerminalHandle, mode: number, isAnsi: boolean): number; - - // Scrollback API - ghostty_terminal_get_scrollback_length(terminal: TerminalHandle): number; - ghostty_terminal_get_scrollback_line( + bufLen: number, + outLenPtr: number + ): number; + ghostty_grid_ref_style(refPtr: number, outStylePtr: number): number; + + // Kitty graphics — placement iteration + image lookup. The graphics + // handle comes from ghostty_terminal_get(terminal, KITTY_GRAPHICS, *out) + // and is borrowed: invalidated by ANY mutating terminal call. + ghostty_kitty_graphics_get(graphics: number, key: number, outPtr: number): number; + ghostty_kitty_graphics_image(graphics: number, imageId: number): number; // returns image handle (0 if missing) + ghostty_kitty_graphics_image_get(image: number, key: number, outPtr: number): number; + ghostty_kitty_graphics_image_get_multi( + image: number, + count: number, + keysPtr: number, + valuesPtr: number, + outWrittenPtr: number + ): number; + ghostty_kitty_graphics_placement_iterator_new( + allocatorPtr: number, + outIterPtrPtr: number + ): number; + ghostty_kitty_graphics_placement_iterator_free(iter: number): void; + ghostty_kitty_graphics_placement_iterator_set( + iter: number, + option: number, + valuePtr: number + ): number; + ghostty_kitty_graphics_placement_next(iter: number): boolean; + ghostty_kitty_graphics_placement_get(iter: number, key: number, outPtr: number): number; + ghostty_kitty_graphics_placement_get_multi( + iter: number, + count: number, + keysPtr: number, + valuesPtr: number, + outWrittenPtr: number + ): number; + ghostty_kitty_graphics_placement_rect( + iter: number, + image: number, terminal: TerminalHandle, - offset: number, - bufPtr: number, - bufLen: number - ): number; // Returns cells written or -1 on error - ghostty_terminal_get_scrollback_grapheme( + outSelectionPtr: number + ): number; + ghostty_kitty_graphics_placement_pixel_size( + iter: number, + image: number, terminal: TerminalHandle, - offset: number, - col: number, - bufPtr: number, - bufLen: number - ): number; // Returns codepoint count or -1 on error - ghostty_terminal_is_row_wrapped(terminal: TerminalHandle, row: number): number; - - // Hyperlink API - ghostty_terminal_get_hyperlink_uri( + outWidthPtr: number, + outHeightPtr: number + ): number; + ghostty_kitty_graphics_placement_grid_size( + iter: number, + image: number, terminal: TerminalHandle, - row: number, - col: number, - bufPtr: number, - bufLen: number - ): number; // Returns bytes written, 0 if no hyperlink, -1 on error - ghostty_terminal_get_scrollback_hyperlink_uri( + outColsPtr: number, + outRowsPtr: number + ): number; + ghostty_kitty_graphics_placement_viewport_pos( + iter: number, + image: number, terminal: TerminalHandle, - offset: number, - col: number, - bufPtr: number, - bufLen: number - ): number; // Returns bytes written, 0 if no hyperlink, -1 on error + outColPtr: number, + outRowPtr: number + ): number; + ghostty_kitty_graphics_placement_source_rect( + iter: number, + image: number, + outX: number, + outY: number, + outW: number, + outH: number + ): number; + // The all-in-one render path: fills a 44-byte PlacementRenderInfo + // sized struct in a single call. Use this in the hot render loop + // instead of stringing together pixel_size + grid_size + viewport_pos + // + source_rect. + ghostty_kitty_graphics_placement_render_info( + iter: number, + image: number, + terminal: TerminalHandle, + outInfoPtr: number + ): number; - // Response API (for DSR and other terminal queries) - ghostty_terminal_has_response(terminal: TerminalHandle): boolean; - ghostty_terminal_read_response(terminal: TerminalHandle, bufPtr: number, bufLen: number): number; // Returns bytes written, 0 if no response, -1 on error + // Generic terminal property API. Mirrors render_state_get/set: a single + // entry point keyed by GhosttyTerminalData (see TerminalData enum). + ghostty_terminal_get(terminal: TerminalHandle, key: number, outPtr: number): number; + ghostty_terminal_get_multi( + terminal: TerminalHandle, + count: number, + keysPtr: number, + valuesPtr: number, + outWrittenPtr: number + ): number; + ghostty_terminal_set(terminal: TerminalHandle, option: number, valuePtr: number): number; + // System-wide options (process-global / per-WASM-instance). Used to + // install the PNG decoder callback for kitty graphics PNG payloads. + ghostty_sys_set(option: number, valuePtr: number): number; + // Allocate / free memory through the library's allocator. Used by + // callbacks (e.g. the PNG decoder) that need to hand WASM-allocated + // buffers back to the library. + ghostty_alloc(allocatorPtr: number, len: number): number; + ghostty_free(allocatorPtr: number, ptr: number, len: number): void; + // Mode queries: mode is a packed u16 (low 15 bits = mode value, bit 15 = ANSI flag). + ghostty_terminal_mode_get(terminal: TerminalHandle, mode: number, outBoolPtr: number): number; + ghostty_terminal_mode_set(terminal: TerminalHandle, mode: number, value: boolean): number; + // grid_ref / point_from_grid_ref: row/cell-level access. Not yet wired + // up on the TS side (used to implement isRowWrapped / getHyperlinkUri / + // scrollback iteration). + // Response handling moved to a callback model: install via + // ghostty_terminal_set(GHOSTTY_TERMINAL_OPT_WRITE_PTY, callback). Old + // has_response / read_response polling API is gone. } // ============================================================================ @@ -486,7 +595,7 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { // ============================================================================ /** - * Dirty state from RenderState + * Dirty state from RenderState. Mirrors GhosttyRenderStateDirty. */ export enum DirtyState { NONE = 0, @@ -494,6 +603,358 @@ export enum DirtyState { FULL = 2, } +/** + * Keys for ghostty_render_state_get(). Mirrors GhosttyRenderStateData. + */ +export enum RenderStateData { + COLS = 1, + ROWS = 2, + DIRTY = 3, + ROW_ITERATOR = 4, + COLOR_BACKGROUND = 5, + COLOR_FOREGROUND = 6, + COLOR_CURSOR = 7, + COLOR_CURSOR_HAS_VALUE = 8, + COLOR_PALETTE = 9, + CURSOR_VISUAL_STYLE = 10, + CURSOR_VISIBLE = 11, + CURSOR_BLINKING = 12, + CURSOR_PASSWORD_INPUT = 13, + CURSOR_VIEWPORT_HAS_VALUE = 14, + CURSOR_VIEWPORT_X = 15, + CURSOR_VIEWPORT_Y = 16, + CURSOR_VIEWPORT_WIDE_TAIL = 17, +} + +/** + * Options for ghostty_render_state_set(). Mirrors GhosttyRenderStateOption. + */ +export enum RenderStateOption { + DIRTY = 0, +} + +/** + * Visual cursor style. Mirrors GhosttyRenderStateCursorVisualStyle. + */ +export enum CursorVisualStyle { + BAR = 0, + BLOCK = 1, + UNDERLINE = 2, + BLOCK_HOLLOW = 3, +} + +/** + * Keys for ghostty_terminal_get(). Mirrors GhosttyTerminalData. + * Only entries actually used by the TS layer are listed here; the upstream + * enum has more (TITLE, PWD, SCROLLBAR, KITTY_KEYBOARD_FLAGS, palettes, ...). + */ +export enum TerminalData { + COLS = 1, + ROWS = 2, + CURSOR_X = 3, + CURSOR_Y = 4, + CURSOR_PENDING_WRAP = 5, + ACTIVE_SCREEN = 6, + CURSOR_VISIBLE = 7, + KITTY_KEYBOARD_FLAGS = 8, + SCROLLBAR = 9, + CURSOR_STYLE = 10, + MOUSE_TRACKING = 11, + TITLE = 12, + PWD = 13, + TOTAL_ROWS = 14, + SCROLLBACK_ROWS = 15, + WIDTH_PX = 16, + HEIGHT_PX = 17, + COLOR_FOREGROUND = 18, + COLOR_BACKGROUND = 19, + COLOR_CURSOR = 20, + COLOR_PALETTE = 21, + COLOR_FOREGROUND_DEFAULT = 22, + COLOR_BACKGROUND_DEFAULT = 23, + COLOR_CURSOR_DEFAULT = 24, + COLOR_PALETTE_DEFAULT = 25, + KITTY_IMAGE_STORAGE_LIMIT = 26, + KITTY_GRAPHICS = 30, +} + +/** + * Options for ghostty_terminal_set(). Mirrors GhosttyTerminalOption. + * Only the entries the TS layer touches are listed; the upstream enum has + * more (callbacks for BELL/TITLE_CHANGED/etc., kitty-image limits, ...). + */ +export enum TerminalOption { + USERDATA = 0, + WRITE_PTY = 1, + BELL = 2, + ENQUIRY = 3, + XTVERSION = 4, + TITLE_CHANGED = 5, + SIZE = 6, + COLOR_FOREGROUND = 11, + COLOR_BACKGROUND = 12, + COLOR_CURSOR = 13, + COLOR_PALETTE = 14, + KITTY_IMAGE_STORAGE_LIMIT = 15, +} + +/** + * Options for ghostty_sys_set(). Mirrors GhosttySysOption. + * Process-global / per-WASM-instance settings. + */ +export enum SysOption { + USERDATA = 0, + DECODE_PNG = 1, + LOG = 2, +} + +/** + * Keys for ghostty_kitty_graphics_get(). Mirrors GhosttyKittyGraphicsData. + */ +export enum KittyGraphicsData { + PLACEMENT_ITERATOR = 1, +} + +/** + * Keys for ghostty_kitty_graphics_placement_get(). Mirrors + * GhosttyKittyGraphicsPlacementData. All values are u32 except Z (i32). + */ +export enum KittyGraphicsPlacementData { + IMAGE_ID = 1, + PLACEMENT_ID = 2, + IS_VIRTUAL = 3, + X_OFFSET = 4, + Y_OFFSET = 5, + SOURCE_X = 6, + SOURCE_Y = 7, + SOURCE_WIDTH = 8, + SOURCE_HEIGHT = 9, + COLUMNS = 10, + ROWS = 11, + Z = 12, +} + +/** + * Keys for ghostty_kitty_graphics_image_get(). Mirrors GhosttyKittyGraphicsImageData. + */ +export enum KittyGraphicsImageData { + ID = 1, + NUMBER = 2, + WIDTH = 3, + HEIGHT = 4, + FORMAT = 5, + COMPRESSION = 6, + DATA_PTR = 7, + DATA_LEN = 8, +} + +/** + * Z-layer filter for the placement iterator. Mirrors GhosttyKittyPlacementLayer. + */ +export enum KittyGraphicsPlacementLayer { + ALL = 0, + BELOW_BG = 1, + BELOW_TEXT = 2, + ABOVE_TEXT = 3, +} + +/** + * Settable options on the placement iterator. Mirrors + * GhosttyKittyGraphicsPlacementIteratorOption. + */ +export enum KittyGraphicsPlacementIteratorOption { + LAYER = 0, +} + +/** + * Pixel format of a Kitty graphics image. Mirrors GhosttyKittyImageFormat. + * RGB: 24-bit, 3 bytes/px + * RGBA: 32-bit, 4 bytes/px (the canvas-friendly path) + * PNG: compressed; needs a JS-side decoder hooked up via + * ghostty_sys_set(DECODE_PNG, fn) + * GRAY_ALPHA: 16-bit, 2 bytes/px + * GRAY: 8-bit, 1 byte/px + */ +export enum KittyImageFormat { + RGB = 0, + RGBA = 1, + PNG = 2, + GRAY_ALPHA = 3, + GRAY = 4, +} + +/** + * Compression of a Kitty graphics image. Mirrors GhosttyKittyImageCompression. + */ +export enum KittyImageCompression { + NONE = 0, + ZLIB_DEFLATE = 1, +} + +/** + * Parsed GhosttyKittyGraphicsPlacementRenderInfo — everything the renderer + * needs about a single placement to composite it on the canvas. + * + * Wire layout on wasm32 (48 bytes, extern struct, 4-byte aligned): + * size: u32 @ 0 (sized-struct discriminator; we just write 48) + * pixel_width: u32 @ 4 + * pixel_height: u32 @ 8 + * grid_cols: u32 @ 12 + * grid_rows: u32 @ 16 + * viewport_col: i32 @ 20 + * viewport_row: i32 @ 24 + * viewport_visible: bool @ 28 (1 byte + 3 bytes padding to next u32) + * source_x: u32 @ 32 + * source_y: u32 @ 36 + * source_width: u32 @ 40 + * source_height: u32 @ 44 + */ +export interface KittyPlacementInfo { + imageId: number; + /** Destination size on the canvas, in pixels. */ + pixelWidth: number; + pixelHeight: number; + /** Destination size on the grid, in cells. */ + gridCols: number; + gridRows: number; + /** Top-left in viewport-relative cells. Negative when scrolled partway off the top. */ + viewportCol: number; + viewportRow: number; + /** Whether any part of the placement intersects the visible viewport. */ + viewportVisible: boolean; + /** Source rect within the image, in pixels (already clamped to image bounds). */ + sourceX: number; + sourceY: number; + sourceWidth: number; + sourceHeight: number; + /** + * Virtual placements have no fixed viewport position; their image is + * drawn into U+10EEEE placeholder cells written to the grid by the + * application. The renderer picks them up by image_id rather than + * iterating through them for direct compositing. + */ + isVirtual: boolean; +} + +/** Size in bytes of GhosttyKittyGraphicsPlacementRenderInfo on wasm32. */ +export const KITTY_PLACEMENT_RENDER_INFO_SIZE = 48; + +/** + * Image bytes + metadata returned by GhosttyTerminal.getKittyImageRgba. + * `data` is a *view* into WASM memory and is invalidated by the next + * mutating terminal call — copy out before vt_write if you need to retain. + */ +export interface KittyImagePixels { + width: number; + height: number; + format: KittyImageFormat; + /** Borrowed view into WASM memory; copy before vt_write to retain. */ + data: Uint8Array; +} + +/** + * Active screen identifier. Mirrors GhosttyTerminalScreen. + * Returned as the value for TerminalData.ACTIVE_SCREEN. + */ +export enum TerminalScreen { + PRIMARY = 0, + ALTERNATE = 1, +} + +/** + * Keys for ghostty_render_state_row_get(). Mirrors GhosttyRenderStateRowData. + */ +export enum RenderStateRowData { + DIRTY = 1, + RAW = 2, + CELLS = 3, +} + +/** + * Options for ghostty_render_state_row_set(). Mirrors GhosttyRenderStateRowOption. + */ +export enum RenderStateRowOption { + DIRTY = 0, +} + +/** + * Keys for ghostty_render_state_row_cells_get(). Mirrors + * GhosttyRenderStateRowCellsData. + */ +export enum RowCellsData { + RAW = 1, + STYLE = 2, + GRAPHEMES_LEN = 3, + GRAPHEMES_BUF = 4, + BG_COLOR = 5, + FG_COLOR = 6, +} + +/** + * Keys for ghostty_row_get(). Mirrors GhosttyRowData. Used with the raw + * GhosttyRow value obtained via _render_state_row_get(iter, RAW, &row). + */ +export enum RowData { + WRAP = 1, + WRAP_CONTINUATION = 2, + GRAPHEME = 3, + STYLED = 4, + HYPERLINK = 5, +} + +/** + * Tag values for GhosttyPoint. Mirrors GhosttyPointTag. The tag selects + * which coordinate space y is interpreted in. + */ +export enum PointTag { + ACTIVE = 0, + VIEWPORT = 1, + SCREEN = 2, + HISTORY = 3, +} + +/** + * Keys for ghostty_cell_get(). Mirrors GhosttyCellData. Used with the + * raw GhosttyCell value obtained via grid_ref_cell or row_cells_get(RAW). + */ +export enum CellData { + CODEPOINT = 1, + CONTENT_TAG = 2, + WIDE = 3, + HAS_TEXT = 4, + HAS_STYLING = 5, + STYLE_ID = 6, + HAS_HYPERLINK = 7, + PROTECTED = 8, + SEMANTIC_CONTENT = 9, + COLOR_PALETTE = 10, + COLOR_RGB = 11, +} + +/** + * Cell width classification. Mirrors GhosttyCellWide. + * NARROW: single-column cell (most ASCII, BMP) + * WIDE: leading half of a double-width cell (CJK, most emoji) + * SPACER_TAIL: trailing half of a wide cell — placeholder, no glyph + * SPACER_HEAD: leading placeholder when a wide cell would have crossed + * the right margin and got pushed to the next row + */ +export enum CellWide { + NARROW = 0, + WIDE = 1, + SPACER_TAIL = 2, + SPACER_HEAD = 3, +} + +/** + * Pack a terminal mode number + ANSI flag into the u16 wire format used by + * ghostty_terminal_mode_get/_set. Bits 0–14 hold the value (u15), bit 15 + * is set for ANSI modes (cleared for DEC private modes). + */ +export function packMode(mode: number, isAnsi: boolean): number { + return (mode & 0x7fff) | (isAnsi ? 0x8000 : 0); +} + /** * Cursor state from RenderState (8 bytes packed) * Layout: x(u16) + y(u16) + viewport_x(i16) + viewport_y(i16) + visible(bool) + blinking(bool) + style(u8) + _pad(u8) @@ -556,12 +1017,18 @@ export type TerminalHandle = number; */ export interface GhosttyCell { codepoint: number; // u32 (Unicode codepoint - first codepoint of grapheme) - fg_r: number; // u8 (foreground red) + fg_r: number; // u8 (foreground red, valid only when fgIsDefault is false) fg_g: number; // u8 (foreground green) fg_b: number; // u8 (foreground blue) - bg_r: number; // u8 (background red) + bg_r: number; // u8 (background red, valid only when bgIsDefault is false) bg_g: number; // u8 (background green) bg_b: number; // u8 (background blue) + // Whether the cell has an explicit fg/bg color or should use the + // terminal's default. Mirrors the GhosttyStyleColor tag (NONE = default). + // The renderer must consult these instead of treating RGB(0,0,0) as + // "default" — explicit literal black is a valid color. + fgIsDefault: boolean; + bgIsDefault: boolean; flags: number; // u8 (style flags bitfield) width: number; // u8 (character width: 1=normal, 2=wide, etc.) hyperlink_id: number; // u16 (0 = no link, >0 = hyperlink ID in set) diff --git a/lib/write_pty_trampoline.ts b/lib/write_pty_trampoline.ts new file mode 100644 index 0000000..f0adee2 --- /dev/null +++ b/lib/write_pty_trampoline.ts @@ -0,0 +1,112 @@ +/** + * Tiny WASM trampolines that let us install JS callbacks into the main + * libghostty-vt module's __indirect_function_table. + * + * Why this exists: ghostty_terminal_set / ghostty_sys_set take function + * pointers (table indices in WASM-land). To put a JS function at a given + * table index we'd normally use `new WebAssembly.Function(...)`, but + * that's part of the Type Reflection proposal which only Chrome ships — + * Bun and Node both report `typeof WebAssembly.Function === 'undefined'`. + * + * Workaround: instantiate a tiny separate WASM module that imports JS + * callbacks (one per signature) and exports matching wrappers. Each + * exported funcref is portable across modules with compatible funcref + * tables, so we can add it to the main module's table and pass the + * index to terminal_set / sys_set. + * + * Currently bridged: + * WRITE_PTY: (terminal, userdata, data, len) -> void + * For DSR replies, in-band size reports, XTVERSION, etc. + * SIZE: (terminal, userdata, out_size) -> bool + * For CSI 14/16/18 t (XTWINOPS) — embedder fills the out_size struct. + * DECODE_PNG: (userdata, allocator, data, data_len, out_image) -> bool + * For kitty graphics PNG payloads — decoder allocates RGBA via + * ghostty_alloc(allocator, len) and fills the 16-byte + * GhosttySysImage at out_image. + * + * The bytes below are the output of: + * wat2wasm lib/write_pty_trampoline.wat -o /tmp/trampoline.wasm + * + * Source is in write_pty_trampoline.wat — keep both in sync if you edit. + */ +const TRAMPOLINE_BYTES = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x18, 0x03, 0x60, 0x04, 0x7f, 0x7f, 0x7f, + 0x7f, 0x00, 0x60, 0x03, 0x7f, 0x7f, 0x7f, 0x01, 0x7f, 0x60, 0x05, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, + 0x01, 0x7f, 0x02, 0x36, 0x03, 0x03, 0x65, 0x6e, 0x76, 0x0c, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, + 0x70, 0x74, 0x79, 0x5f, 0x63, 0x62, 0x00, 0x00, 0x03, 0x65, 0x6e, 0x76, 0x07, 0x73, 0x69, 0x7a, + 0x65, 0x5f, 0x63, 0x62, 0x00, 0x01, 0x03, 0x65, 0x6e, 0x76, 0x0d, 0x64, 0x65, 0x63, 0x6f, 0x64, + 0x65, 0x5f, 0x70, 0x6e, 0x67, 0x5f, 0x63, 0x62, 0x00, 0x02, 0x03, 0x04, 0x03, 0x00, 0x01, 0x02, + 0x07, 0x2d, 0x03, 0x0d, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x70, 0x74, 0x79, 0x5f, 0x66, 0x77, + 0x64, 0x00, 0x03, 0x08, 0x73, 0x69, 0x7a, 0x65, 0x5f, 0x66, 0x77, 0x64, 0x00, 0x04, 0x0e, 0x64, + 0x65, 0x63, 0x6f, 0x64, 0x65, 0x5f, 0x70, 0x6e, 0x67, 0x5f, 0x66, 0x77, 0x64, 0x00, 0x05, 0x0a, + 0x28, 0x03, 0x0c, 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x20, 0x03, 0x10, 0x00, 0x0b, 0x0a, + 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x10, 0x01, 0x0b, 0x0e, 0x00, 0x20, 0x00, 0x20, 0x01, + 0x20, 0x02, 0x20, 0x03, 0x20, 0x04, 0x10, 0x02, 0x0b, +]); + +export type WritePtyCallback = ( + terminal: number, + userdata: number, + dataPtr: number, + dataLen: number +) => void; + +/** + * SIZE callback: writes its result into out_size (a 12-byte + * GhosttySizeReportSize struct: rows@0:u16, cols@2:u16, cell_w@4:u32, + * cell_h@8:u32) and returns 1 to indicate "responded" or 0 to drop the + * query. + */ +export type SizeCallback = (terminal: number, userdata: number, outSizePtr: number) => number; + +/** + * DECODE_PNG callback: receives PNG bytes at dataPtr / dataLen, decodes + * to RGBA, allocates a buffer via ghostty_alloc(allocator, rgbaLen), + * fills the 16-byte GhosttySysImage at outImagePtr (u32 width @ 0, + * u32 height @ 4, u32 data_ptr @ 8, u32 data_len @ 12), and returns 1 + * on success or 0 to indicate decode failure. + */ +export type DecodePngCallback = ( + userdata: number, + allocator: number, + dataPtr: number, + dataLen: number, + outImagePtr: number +) => number; + +/** + * Compile the trampoline once, then instantiate per-Ghostty with the JS + * callbacks as the `env.*_cb` imports. Returns all three exported + * wrappers — funcrefs callable from any WASM module via call_indirect. + */ +let compiled: WebAssembly.Module | null = null; + +export interface TrampolineExports { + // Funcrefs for installation into the main module's + // __indirect_function_table. Their JS-side type matches their + // corresponding callback signatures since the trampoline body just + // forwards arguments through. + writePtyFwd: WritePtyCallback; + sizeFwd: SizeCallback; + decodePngFwd: DecodePngCallback; +} + +export function makeCallbackTrampolines( + writePtyCb: WritePtyCallback, + sizeCb: SizeCallback, + decodePngCb: DecodePngCallback +): TrampolineExports { + if (!compiled) compiled = new WebAssembly.Module(TRAMPOLINE_BYTES); + const inst = new WebAssembly.Instance(compiled, { + env: { + write_pty_cb: writePtyCb, + size_cb: sizeCb, + decode_png_cb: decodePngCb, + }, + }); + return { + writePtyFwd: inst.exports.write_pty_fwd as unknown as WritePtyCallback, + sizeFwd: inst.exports.size_fwd as unknown as SizeCallback, + decodePngFwd: inst.exports.decode_png_fwd as unknown as DecodePngCallback, + }; +} diff --git a/lib/write_pty_trampoline.wat b/lib/write_pty_trampoline.wat new file mode 100644 index 0000000..c3160f9 --- /dev/null +++ b/lib/write_pty_trampoline.wat @@ -0,0 +1,44 @@ +;; Tiny trampolines so we can install JS callbacks into the main wasm +;; module's __indirect_function_table without WebAssembly.Function support +;; (Bun and Node lack it; only modern browsers ship the Type Reflection +;; proposal). +;; +;; Each trampoline imports a JS function from `env` and re-exports a +;; wrapper with the matching libghostty-vt callback signature. The +;; wrapper's exported funcref can be added to the main module's table, +;; where ghostty_terminal_set(OPT_*, idx) wires it up. +;; +;; Callbacks currently bridged: +;; WRITE_PTY: (terminal: i32, userdata: i32, data: i32, len: i32) -> nil +;; Used for DSR replies, in-band size reports, etc. +;; SIZE: (terminal: i32, userdata: i32, out_size: i32) -> i32 (bool) +;; Used for CSI 14/16/18 t responses; embedder fills out_size. +;; DECODE_PNG: (userdata: i32, allocator: i32, data: i32, data_len: i32, +;; out_image: i32) -> i32 (bool) +;; Used for kitty graphics PNG payloads. Decoder allocates RGBA via +;; ghostty_alloc(allocator, len) and fills out_image (16-byte struct +;; of u32 width, u32 height, u32 data_ptr, u32 data_len). +;; +;; Rebuild after edits: +;; wat2wasm lib/write_pty_trampoline.wat -o /tmp/trampoline.wasm +;; Then update the byte literal in lib/write_pty_trampoline.ts. +(module + (type $write_pty_sig (func (param i32 i32 i32 i32))) + (type $size_sig (func (param i32 i32 i32) (result i32))) + (type $decode_png_sig (func (param i32 i32 i32 i32 i32) (result i32))) + + (import "env" "write_pty_cb" (func $write_pty_cb (type $write_pty_sig))) + (import "env" "size_cb" (func $size_cb (type $size_sig))) + (import "env" "decode_png_cb" (func $decode_png_cb (type $decode_png_sig))) + + (func $write_pty_fwd (export "write_pty_fwd") (type $write_pty_sig) + local.get 0 local.get 1 local.get 2 local.get 3 + call $write_pty_cb) + + (func $size_fwd (export "size_fwd") (type $size_sig) + local.get 0 local.get 1 local.get 2 + call $size_cb) + + (func $decode_png_fwd (export "decode_png_fwd") (type $decode_png_sig) + local.get 0 local.get 1 local.get 2 local.get 3 local.get 4 + call $decode_png_cb)) diff --git a/package.json b/package.json index 0b93cab..42cbd7c 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,9 @@ "lint:fix": "biome check --write .", "prepublishOnly": "bun run build" }, + "dependencies": { + "fast-png": "^7.0.0" + }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@happy-dom/global-registrator": "^15.11.0", diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index bd3feb9..a3e5f6b 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -1,1591 +1,116 @@ -diff --git a/.gitignore b/.gitignore -index e451b171a..89c623d8b 100644 ---- a/.gitignore -+++ b/.gitignore -@@ -23,3 +23,4 @@ glad.zip - /ghostty.qcow2 +diff --git a/src/terminal/build_options.zig b/src/terminal/build_options.zig +index 136e0f101..a1d9215cf 100644 +--- a/src/terminal/build_options.zig ++++ b/src/terminal/build_options.zig +@@ -64,11 +64,14 @@ pub const Options = struct { + // We disable it on wasm32-freestanding because we at the least + // require the ability to get timestamps and there is no way to + // do that with freestanding targets. +- const target = m.resolved_target.?.result; ++ // ghostty-web: enabled with a monotonic-counter shim in ++ // src/terminal/kitty/graphics_image.zig (see Timestamp / ++ // transmitTimeNow) and a comptime guard against non-direct ++ // mediums. + opts.addOption( + bool, + "kitty_graphics", +- !(target.cpu.arch == .wasm32 and target.os.tag == .freestanding), ++ true, + ); - vgcore.* -+node_modules/ -diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h -index 4f8fef88e..ca9fb1d4d 100644 ---- a/include/ghostty/vt.h -+++ b/include/ghostty/vt.h -@@ -28,6 +28,7 @@ - * @section groups_sec API Reference - * - * The API is organized into the following groups: -+ * - @ref terminal "Terminal Emulator" - Complete terminal emulator with VT parsing - * - @ref key "Key Encoding" - Encode key events into terminal sequences - * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences - * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences -@@ -74,6 +75,7 @@ extern "C" { + // These are synthesized based on other options. +diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig +index 8243a6323..0365263ef 100644 +--- a/src/terminal/kitty/graphics_image.zig ++++ b/src/terminal/kitty/graphics_image.zig +@@ -18,6 +18,31 @@ const temp_dir = struct { - #include - #include -+#include - #include - #include - #include -diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h -new file mode 100644 -index 000000000..c467102c3 ---- /dev/null -+++ b/include/ghostty/vt/terminal.h -@@ -0,0 +1,285 @@ -+/** -+ * @file terminal.h -+ * -+ * Minimal, high-performance terminal emulator API for WASM. -+ * -+ * The key optimization is the RenderState API which provides a pre-computed -+ * snapshot of all render data in a single update call, avoiding multiple -+ * WASM boundary crossings. -+ * -+ * Basic usage: -+ * 1. Create terminal: ghostty_terminal_new(80, 24) -+ * 2. Write data: ghostty_terminal_write(term, data, len) -+ * 3. Each frame: -+ * - ghostty_render_state_update(term) -+ * - ghostty_render_state_get_viewport(term, buffer, size) -+ * - Render the buffer -+ * - ghostty_render_state_mark_clean(term) -+ * 4. Free: ghostty_terminal_free(term) -+ */ -+ -+#ifndef GHOSTTY_VT_TERMINAL_H -+#define GHOSTTY_VT_TERMINAL_H -+ -+#include -+#include -+#include -+ -+#ifdef __cplusplus -+extern "C" { -+#endif -+ -+/** Opaque terminal handle */ -+typedef void* GhosttyTerminal; -+ -+/** -+ * Terminal configuration. -+ * All color values use 0xRRGGBB format. A value of 0 means "use default". -+ */ -+typedef struct { -+ /** Maximum scrollback lines (0 = unlimited) */ -+ uint32_t scrollback_limit; -+ /** Default foreground color (0xRRGGBB, 0 = default) */ -+ uint32_t fg_color; -+ /** Default background color (0xRRGGBB, 0 = default) */ -+ uint32_t bg_color; -+ /** Cursor color (0xRRGGBB, 0 = default) */ -+ uint32_t cursor_color; -+ /** ANSI color palette (16 colors, 0xRRGGBB format, 0 = default) */ -+ uint32_t palette[16]; -+} GhosttyTerminalConfig; -+ -+/** Cell structure - 16 bytes, pre-resolved colors */ -+typedef struct { -+ uint32_t codepoint; -+ uint8_t fg_r, fg_g, fg_b; -+ uint8_t bg_r, bg_g, bg_b; -+ uint8_t flags; -+ uint8_t width; -+ uint16_t hyperlink_id; -+ uint8_t grapheme_len; /* Number of extra codepoints beyond first (0 = no grapheme) */ -+ uint8_t _pad; -+} GhosttyCell; -+ -+/** Cell flags */ -+#define GHOSTTY_CELL_BOLD (1 << 0) -+#define GHOSTTY_CELL_ITALIC (1 << 1) -+#define GHOSTTY_CELL_UNDERLINE (1 << 2) -+#define GHOSTTY_CELL_STRIKETHROUGH (1 << 3) -+#define GHOSTTY_CELL_INVERSE (1 << 4) -+#define GHOSTTY_CELL_INVISIBLE (1 << 5) -+#define GHOSTTY_CELL_BLINK (1 << 6) -+#define GHOSTTY_CELL_FAINT (1 << 7) -+ -+/** Dirty state */ -+typedef enum { -+ GHOSTTY_DIRTY_NONE = 0, -+ GHOSTTY_DIRTY_PARTIAL = 1, -+ GHOSTTY_DIRTY_FULL = 2 -+} GhosttyDirty; -+ -+/* ============================================================================ -+ * Lifecycle -+ * ========================================================================= */ -+ -+/** Create a new terminal with default settings */ -+GhosttyTerminal ghostty_terminal_new(int cols, int rows); -+ -+/** -+ * Create a new terminal with custom configuration. -+ * @param cols Number of columns -+ * @param rows Number of rows -+ * @param config Configuration options (NULL = use defaults) -+ * @return Terminal handle, or NULL on failure -+ */ -+GhosttyTerminal ghostty_terminal_new_with_config( -+ int cols, -+ int rows, -+ const GhosttyTerminalConfig* config -+); -+ -+/** Free a terminal */ -+void ghostty_terminal_free(GhosttyTerminal term); -+ -+/** Resize terminal */ -+void ghostty_terminal_resize(GhosttyTerminal term, int cols, int rows); -+ -+/** Write data to terminal (parses VT sequences) */ -+void ghostty_terminal_write(GhosttyTerminal term, const uint8_t* data, size_t len); -+ -+/* ============================================================================ -+ * RenderState API - High-performance rendering -+ * ========================================================================= */ -+ -+/** Update render state from terminal. Call once per frame. */ -+GhosttyDirty ghostty_render_state_update(GhosttyTerminal term); -+ -+/** Get dimensions */ -+int ghostty_render_state_get_cols(GhosttyTerminal term); -+int ghostty_render_state_get_rows(GhosttyTerminal term); -+ -+/** Get cursor state (individual getters for WASM efficiency) */ -+int ghostty_render_state_get_cursor_x(GhosttyTerminal term); -+int ghostty_render_state_get_cursor_y(GhosttyTerminal term); -+bool ghostty_render_state_get_cursor_visible(GhosttyTerminal term); -+ -+/** Get default colors as 0xRRGGBB */ -+uint32_t ghostty_render_state_get_bg_color(GhosttyTerminal term); -+uint32_t ghostty_render_state_get_fg_color(GhosttyTerminal term); -+ -+/** Check if a row is dirty */ -+bool ghostty_render_state_is_row_dirty(GhosttyTerminal term, int y); -+ -+/** Mark render state as clean (call after rendering) */ -+void ghostty_render_state_mark_clean(GhosttyTerminal term); -+ -+/** -+ * Get ALL viewport cells in one call - the key performance optimization! -+ * Buffer must be at least (rows * cols) cells. -+ * Returns total cells written, or -1 on error. -+ */ -+int ghostty_render_state_get_viewport( -+ GhosttyTerminal term, -+ GhosttyCell* out_buffer, -+ size_t buffer_size -+); -+ -+/** -+ * Get grapheme codepoints for a cell at (row, col). -+ * For cells with grapheme_len > 0, this returns all codepoints that make up -+ * the grapheme cluster. The buffer receives u32 codepoints. -+ * @param row Row index (0-based) -+ * @param col Column index (0-based) -+ * @param out_buffer Buffer to receive codepoints -+ * @param buffer_size Size of buffer in u32 elements -+ * @return Number of codepoints written (including the first), or -1 on error -+ */ -+int ghostty_render_state_get_grapheme( -+ GhosttyTerminal term, -+ int row, -+ int col, -+ uint32_t* out_buffer, -+ size_t buffer_size -+); -+ -+/* ============================================================================ -+ * Terminal Modes -+ * ========================================================================= */ -+ -+/** Check if alternate screen is active */ -+bool ghostty_terminal_is_alternate_screen(GhosttyTerminal term); -+ -+/** Check if any mouse tracking mode is enabled */ -+bool ghostty_terminal_has_mouse_tracking(GhosttyTerminal term); -+ -+/** -+ * Query arbitrary terminal mode by number. -+ * @param mode Mode number (e.g., 25 for cursor visibility, 2004 for bracketed paste) -+ * @param is_ansi true for ANSI modes, false for DEC modes -+ * @return true if mode is enabled -+ */ -+bool ghostty_terminal_get_mode(GhosttyTerminal term, int mode, bool is_ansi); -+ -+/* ============================================================================ -+ * Scrollback API -+ * ========================================================================= */ -+ -+/** Get number of scrollback lines (history, not including active screen) */ -+int ghostty_terminal_get_scrollback_length(GhosttyTerminal term); -+ -+/** -+ * Get a line from the scrollback buffer. -+ * @param offset 0 = oldest line, (length-1) = most recent scrollback line -+ * @param out_buffer Buffer to write cells to -+ * @param buffer_size Size of buffer in cells (must be >= cols) -+ * @return Number of cells written, or -1 on error -+ */ -+int ghostty_terminal_get_scrollback_line( -+ GhosttyTerminal term, -+ int offset, -+ GhosttyCell* out_buffer, -+ size_t buffer_size -+); -+ -+/** -+ * Get grapheme codepoints for a cell in the scrollback buffer. -+ * @param offset Scrollback line offset (0 = oldest) -+ * @param col Column index (0-based) -+ * @param out_buffer Buffer to receive codepoints -+ * @param buffer_size Size of buffer in u32 elements -+ * @return Number of codepoints written, or -1 on error -+ */ -+int ghostty_terminal_get_scrollback_grapheme( -+ GhosttyTerminal term, -+ int offset, -+ int col, -+ uint32_t* out_buffer, -+ size_t buffer_size -+); -+ -+/** Check if a row is a continuation from previous row (soft-wrapped) */ -+bool ghostty_terminal_is_row_wrapped(GhosttyTerminal term, int y); -+ -+/* ============================================================================ -+ * Hyperlink API -+ * ========================================================================= */ -+ -+/** -+ * Get the hyperlink URI for a cell in the active viewport. -+ * @param row Row index (0-based) -+ * @param col Column index (0-based) -+ * @param out_buffer Buffer to receive URI bytes (UTF-8) -+ * @param buffer_size Size of buffer in bytes -+ * @return Number of bytes written, 0 if no hyperlink, -1 on error -+ */ -+int ghostty_terminal_get_hyperlink_uri( -+ GhosttyTerminal term, -+ int row, -+ int col, -+ uint8_t* out_buffer, -+ size_t buffer_size -+); -+ -+/** -+ * Get the hyperlink URI for a cell in the scrollback buffer. -+ * @param offset Scrollback line offset (0 = oldest, scrollback_len-1 = newest) -+ * @param col Column index (0-based) -+ * @param out_buffer Buffer to receive URI bytes (UTF-8) -+ * @param buffer_size Size of buffer in bytes -+ * @return Number of bytes written, 0 if no hyperlink, -1 on error -+ */ -+int ghostty_terminal_get_scrollback_hyperlink_uri( -+ GhosttyTerminal term, -+ int offset, -+ int col, -+ uint8_t* out_buffer, -+ size_t buffer_size -+); -+ -+/* ============================================================================ -+ * Response API - for DSR and other terminal queries -+ * ========================================================================= */ -+ -+/** -+ * Check if there are pending responses from the terminal. -+ * Responses are generated by escape sequences like DSR (Device Status Report). -+ */ -+bool ghostty_terminal_has_response(GhosttyTerminal term); -+ -+/** -+ * Read pending responses from the terminal. -+ * @param out_buffer Buffer to write response bytes to -+ * @param buffer_size Size of buffer in bytes -+ * @return Number of bytes written, 0 if no responses pending, -1 on error -+ */ -+int ghostty_terminal_read_response( -+ GhosttyTerminal term, -+ uint8_t* out_buffer, -+ size_t buffer_size -+); -+ -+#ifdef __cplusplus + const log = std.log.scoped(.kitty_gfx); + ++/// ghostty-web: WASM-safe substitute for std.time.Instant. ++/// On freestanding targets there's no clock; we use a monotonic counter ++/// which is sufficient for the LRU-eviction ordering this is used for. ++/// On native, we alias to std.time.Instant for full fidelity. ++pub const Timestamp = if (builtin.target.cpu.arch.isWasm()) struct { ++ value: u64 = 0, ++ ++ pub fn order(self: @This(), other: @This()) std.math.Order { ++ return std.math.order(self.value, other.value); ++ } ++} else std.time.Instant; ++ ++var wasm_next_transmit_time: u64 = 0; ++ ++/// ghostty-web: Get a Timestamp for the current moment. On WASM this is ++/// a counter; on native it's the actual system clock. ++fn transmitTimeNow() !Timestamp { ++ if (comptime builtin.target.cpu.arch.isWasm()) { ++ wasm_next_transmit_time +%= 1; ++ return .{ .value = wasm_next_transmit_time }; ++ } else { ++ return std.time.Instant.now(); ++ } +} -+#endif -+ -+#endif /* GHOSTTY_VT_TERMINAL_H */ -diff --git a/src/lib_vt.zig b/src/lib_vt.zig -index 03a883e20..1336676d7 100644 ---- a/src/lib_vt.zig -+++ b/src/lib_vt.zig -@@ -140,6 +140,45 @@ comptime { - @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); - @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); - @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); -+ // Terminal lifecycle -+ @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); -+ @export(&c.terminal_new_with_config, .{ .name = "ghostty_terminal_new_with_config" }); -+ @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); -+ @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); -+ @export(&c.terminal_write, .{ .name = "ghostty_terminal_write" }); -+ -+ // RenderState API - high-performance rendering -+ @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); -+ @export(&c.render_state_get_cols, .{ .name = "ghostty_render_state_get_cols" }); -+ @export(&c.render_state_get_rows, .{ .name = "ghostty_render_state_get_rows" }); -+ @export(&c.render_state_get_cursor_x, .{ .name = "ghostty_render_state_get_cursor_x" }); -+ @export(&c.render_state_get_cursor_y, .{ .name = "ghostty_render_state_get_cursor_y" }); -+ @export(&c.render_state_get_cursor_visible, .{ .name = "ghostty_render_state_get_cursor_visible" }); -+ @export(&c.render_state_get_bg_color, .{ .name = "ghostty_render_state_get_bg_color" }); -+ @export(&c.render_state_get_fg_color, .{ .name = "ghostty_render_state_get_fg_color" }); -+ @export(&c.render_state_is_row_dirty, .{ .name = "ghostty_render_state_is_row_dirty" }); -+ @export(&c.render_state_mark_clean, .{ .name = "ghostty_render_state_mark_clean" }); -+ @export(&c.render_state_get_viewport, .{ .name = "ghostty_render_state_get_viewport" }); -+ @export(&c.render_state_get_grapheme, .{ .name = "ghostty_render_state_get_grapheme" }); -+ -+ // Terminal modes -+ @export(&c.terminal_is_alternate_screen, .{ .name = "ghostty_terminal_is_alternate_screen" }); -+ @export(&c.terminal_has_mouse_tracking, .{ .name = "ghostty_terminal_has_mouse_tracking" }); -+ @export(&c.terminal_get_mode, .{ .name = "ghostty_terminal_get_mode" }); -+ -+ // Scrollback API -+ @export(&c.terminal_get_scrollback_length, .{ .name = "ghostty_terminal_get_scrollback_length" }); -+ @export(&c.terminal_get_scrollback_line, .{ .name = "ghostty_terminal_get_scrollback_line" }); -+ @export(&c.terminal_get_scrollback_grapheme, .{ .name = "ghostty_terminal_get_scrollback_grapheme" }); -+ @export(&c.terminal_is_row_wrapped, .{ .name = "ghostty_terminal_is_row_wrapped" }); -+ -+ // Hyperlink API -+ @export(&c.terminal_get_hyperlink_uri, .{ .name = "ghostty_terminal_get_hyperlink_uri" }); -+ @export(&c.terminal_get_scrollback_hyperlink_uri, .{ .name = "ghostty_terminal_get_scrollback_hyperlink_uri" }); + -+ // Response API (for DSR and other queries) -+ @export(&c.terminal_has_response, .{ .name = "ghostty_terminal_has_response" }); -+ @export(&c.terminal_read_response, .{ .name = "ghostty_terminal_read_response" }); + /// Maximum width or height of an image. Taken directly from Kitty. + const max_dimension = 10000; - // On Wasm we need to export our allocator convenience functions. - if (builtin.target.cpu.arch.isWasm()) { -diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig -index bc92597f5..d0ee49c1b 100644 ---- a/src/terminal/c/main.zig -+++ b/src/terminal/c/main.zig -@@ -4,6 +4,7 @@ pub const key_event = @import("key_event.zig"); - pub const key_encode = @import("key_encode.zig"); - pub const paste = @import("paste.zig"); - pub const sgr = @import("sgr.zig"); -+pub const terminal = @import("terminal.zig"); +@@ -100,6 +125,14 @@ pub const LoadingImage = struct { + return result; + } - // The full C API, unexported. - pub const osc_new = osc.new; -@@ -52,6 +53,46 @@ pub const key_encoder_encode = key_encode.encode; ++ // ghostty-web: on freestanding/WASM we have no filesystem or shared ++ // memory, so any non-direct medium is unsupported. Bail here before ++ // the code below, which references std.fs.max_path_bytes and ++ // posix.realpath — both fail to compile on freestanding. ++ if (comptime builtin.target.cpu.arch.isWasm()) { ++ return error.UnsupportedMedium; ++ } ++ + // Verify our capabilities and limits allow this. + { + // Special case if we don't support decoding PNGs and the format +@@ -402,7 +435,7 @@ pub const LoadingImage = struct { + } - pub const paste_is_safe = paste.is_safe; + // Set our time +- self.image.transmit_time = std.time.Instant.now() catch |err| { ++ self.image.transmit_time = transmitTimeNow() catch |err| { + log.warn("failed to get time: {}", .{err}); + return error.InternalError; + }; +@@ -512,7 +545,7 @@ pub const Image = struct { + format: command.Transmission.Format = .rgb, + compression: command.Transmission.Compression = .none, + data: []const u8 = "", +- transmit_time: std.time.Instant = undefined, ++ transmit_time: Timestamp = undefined, -+// Terminal lifecycle -+pub const terminal_new = terminal.new; -+pub const terminal_new_with_config = terminal.newWithConfig; -+pub const terminal_free = terminal.free; -+pub const terminal_resize = terminal.resize; -+pub const terminal_write = terminal.write; -+ -+// RenderState API - high-performance rendering -+pub const render_state_update = terminal.renderStateUpdate; -+pub const render_state_get_cols = terminal.renderStateGetCols; -+pub const render_state_get_rows = terminal.renderStateGetRows; -+pub const render_state_get_cursor_x = terminal.renderStateGetCursorX; -+pub const render_state_get_cursor_y = terminal.renderStateGetCursorY; -+pub const render_state_get_cursor_visible = terminal.renderStateGetCursorVisible; -+pub const render_state_get_bg_color = terminal.renderStateGetBgColor; -+pub const render_state_get_fg_color = terminal.renderStateGetFgColor; -+pub const render_state_is_row_dirty = terminal.renderStateIsRowDirty; -+pub const render_state_mark_clean = terminal.renderStateMarkClean; -+pub const render_state_get_viewport = terminal.renderStateGetViewport; -+pub const render_state_get_grapheme = terminal.renderStateGetGrapheme; -+ -+// Terminal modes -+pub const terminal_is_alternate_screen = terminal.isAlternateScreen; -+pub const terminal_has_mouse_tracking = terminal.hasMouseTracking; -+pub const terminal_get_mode = terminal.getMode; -+ -+// Scrollback API -+pub const terminal_get_scrollback_length = terminal.getScrollbackLength; -+pub const terminal_get_scrollback_line = terminal.getScrollbackLine; -+pub const terminal_get_scrollback_grapheme = terminal.getScrollbackGrapheme; -+pub const terminal_is_row_wrapped = terminal.isRowWrapped; -+ -+// Hyperlink API -+pub const terminal_get_hyperlink_uri = terminal.getHyperlinkUri; -+pub const terminal_get_scrollback_hyperlink_uri = terminal.getScrollbackHyperlinkUri; -+ -+// Response API (for DSR and other queries) -+pub const terminal_has_response = terminal.hasResponse; -+pub const terminal_read_response = terminal.readResponse; -+ - test { - _ = color; - _ = osc; -@@ -59,6 +100,7 @@ test { - _ = key_encode; - _ = paste; - _ = sgr; -+ _ = terminal; + /// Set this to true if this image was loaded by a command that + /// doesn't specify an ID or number, since such commands should +diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig +index e017d5f79..c68db764c 100644 +--- a/src/terminal/kitty/graphics_storage.zig ++++ b/src/terminal/kitty/graphics_storage.zig +@@ -9,8 +9,10 @@ const size = @import("../size.zig"); + const command = @import("graphics_command.zig"); + const PageList = @import("../PageList.zig"); + const Screen = @import("../Screen.zig"); +-const LoadingImage = @import("graphics_image.zig").LoadingImage; +-const Image = @import("graphics_image.zig").Image; ++const imagepkg = @import("graphics_image.zig"); ++const LoadingImage = imagepkg.LoadingImage; ++const Image = imagepkg.Image; ++const Timestamp = imagepkg.Timestamp; + const Rect = @import("graphics_image.zig").Rect; + const Command = command.Command; + +@@ -526,7 +528,7 @@ pub const ImageStorage = struct { + // bit is fine compared to the megabytes we're looking to save. + const Candidate = struct { + id: u32, +- time: std.time.Instant, ++ time: Timestamp, + used: bool, + }; - // We want to make sure we run the tests for the C allocator interface. - _ = @import("../../lib/allocator.zig"); -diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig -new file mode 100644 -index 000000000..73ae2e6fa ---- /dev/null -+++ b/src/terminal/c/terminal.zig -@@ -0,0 +1,1123 @@ -+//! C API wrapper for Terminal -+//! -+//! This provides a minimal, high-performance interface to Ghostty's Terminal -+//! for WASM export. The key optimization is using RenderState which provides -+//! a pre-computed snapshot of all render data in a single update call. -+//! -+//! API Design: -+//! - Lifecycle: new, free, resize, write -+//! - Rendering: render_state_update, render_state_get_viewport, etc. -+//! -+//! The RenderState approach means: -+//! - ONE call to update all state (render_state_update) -+//! - ONE call to get all cells (render_state_get_viewport) -+//! - No per-row or per-cell WASM boundary crossings! -+ -+const std = @import("std"); -+const Allocator = std.mem.Allocator; -+const builtin = @import("builtin"); -+ -+const Terminal = @import("../Terminal.zig"); -+const stream = @import("../stream.zig"); -+const Action = stream.Action; -+const ansi = @import("../ansi.zig"); -+const render = @import("../render.zig"); -+const RenderState = render.RenderState; -+const color = @import("../color.zig"); -+const modespkg = @import("../modes.zig"); -+const point = @import("../point.zig"); -+const Style = @import("../style.zig").Style; -+const device_status = @import("../device_status.zig"); -+ -+const log = std.log.scoped(.terminal_c); -+ -+/// Response handler that processes VT sequences and queues responses. -+/// This extends the readonly stream handler to also handle queries. -+const ResponseHandler = struct { -+ alloc: Allocator, -+ terminal: *Terminal, -+ response_buffer: *std.ArrayList(u8), -+ -+ pub fn init(alloc: Allocator, terminal: *Terminal, response_buffer: *std.ArrayList(u8)) ResponseHandler { -+ return .{ -+ .alloc = alloc, -+ .terminal = terminal, -+ .response_buffer = response_buffer, -+ }; -+ } -+ -+ pub fn deinit(self: *ResponseHandler) void { -+ _ = self; -+ } -+ -+ pub fn vt( -+ self: *ResponseHandler, -+ comptime action: Action.Tag, -+ value: Action.Value(action), -+ ) !void { -+ switch (action) { -+ // Device status reports - these need responses -+ .device_status => try self.handleDeviceStatus(value.request), -+ .device_attributes => try self.handleDeviceAttributes(value), -+ -+ // All the terminal state modifications (same as stream_readonly.zig) -+ .print => try self.terminal.print(value.cp), -+ .print_repeat => try self.terminal.printRepeat(value), -+ .backspace => self.terminal.backspace(), -+ .carriage_return => self.terminal.carriageReturn(), -+ .linefeed => try self.terminal.linefeed(), -+ .index => try self.terminal.index(), -+ .next_line => { -+ try self.terminal.index(); -+ self.terminal.carriageReturn(); -+ }, -+ .reverse_index => self.terminal.reverseIndex(), -+ .cursor_up => self.terminal.cursorUp(value.value), -+ .cursor_down => self.terminal.cursorDown(value.value), -+ .cursor_left => self.terminal.cursorLeft(value.value), -+ .cursor_right => self.terminal.cursorRight(value.value), -+ .cursor_pos => self.terminal.setCursorPos(value.row, value.col), -+ .cursor_col => self.terminal.setCursorPos(self.terminal.screens.active.cursor.y + 1, value.value), -+ .cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screens.active.cursor.x + 1), -+ .cursor_col_relative => self.terminal.setCursorPos( -+ self.terminal.screens.active.cursor.y + 1, -+ self.terminal.screens.active.cursor.x + 1 +| value.value, -+ ), -+ .cursor_row_relative => self.terminal.setCursorPos( -+ self.terminal.screens.active.cursor.y + 1 +| value.value, -+ self.terminal.screens.active.cursor.x + 1, -+ ), -+ .cursor_style => { -+ const blink = switch (value) { -+ .default, .steady_block, .steady_bar, .steady_underline => false, -+ .blinking_block, .blinking_bar, .blinking_underline => true, -+ }; -+ const style: @import("../Screen.zig").CursorStyle = switch (value) { -+ .default, .blinking_block, .steady_block => .block, -+ .blinking_bar, .steady_bar => .bar, -+ .blinking_underline, .steady_underline => .underline, -+ }; -+ self.terminal.modes.set(.cursor_blinking, blink); -+ self.terminal.screens.active.cursor.cursor_style = style; -+ }, -+ .erase_display_below => self.terminal.eraseDisplay(.below, value), -+ .erase_display_above => self.terminal.eraseDisplay(.above, value), -+ .erase_display_complete => self.terminal.eraseDisplay(.complete, value), -+ .erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value), -+ .erase_display_scroll_complete => self.terminal.eraseDisplay(.scroll_complete, value), -+ .erase_line_right => self.terminal.eraseLine(.right, value), -+ .erase_line_left => self.terminal.eraseLine(.left, value), -+ .erase_line_complete => self.terminal.eraseLine(.complete, value), -+ .erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value), -+ .delete_chars => self.terminal.deleteChars(value), -+ .erase_chars => self.terminal.eraseChars(value), -+ .insert_lines => self.terminal.insertLines(value), -+ .insert_blanks => self.terminal.insertBlanks(value), -+ .delete_lines => self.terminal.deleteLines(value), -+ .scroll_up => self.terminal.scrollUp(value), -+ .scroll_down => self.terminal.scrollDown(value), -+ .horizontal_tab => try self.horizontalTab(value), -+ .horizontal_tab_back => try self.horizontalTabBack(value), -+ .tab_clear_current => self.terminal.tabClear(.current), -+ .tab_clear_all => self.terminal.tabClear(.all), -+ .tab_set => self.terminal.tabSet(), -+ .tab_reset => self.terminal.tabReset(), -+ .set_mode => try self.setMode(value.mode, true), -+ .reset_mode => try self.setMode(value.mode, false), -+ .save_mode => self.terminal.modes.save(value.mode), -+ .restore_mode => { -+ const v = self.terminal.modes.restore(value.mode); -+ try self.setMode(value.mode, v); -+ }, -+ .top_and_bottom_margin => self.terminal.setTopAndBottomMargin(value.top_left, value.bottom_right), -+ .left_and_right_margin => self.terminal.setLeftAndRightMargin(value.top_left, value.bottom_right), -+ .left_and_right_margin_ambiguous => { -+ if (self.terminal.modes.get(.enable_left_and_right_margin)) { -+ self.terminal.setLeftAndRightMargin(0, 0); -+ } else { -+ self.terminal.saveCursor(); -+ } -+ }, -+ .save_cursor => self.terminal.saveCursor(), -+ .restore_cursor => try self.terminal.restoreCursor(), -+ .invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking), -+ .configure_charset => self.terminal.configureCharset(value.slot, value.charset), -+ .set_attribute => switch (value) { -+ .unknown => {}, -+ else => self.terminal.setAttribute(value) catch {}, -+ }, -+ .protected_mode_off => self.terminal.setProtectedMode(.off), -+ .protected_mode_iso => self.terminal.setProtectedMode(.iso), -+ .protected_mode_dec => self.terminal.setProtectedMode(.dec), -+ .mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false, -+ .kitty_keyboard_push => self.terminal.screens.active.kitty_keyboard.push(value.flags), -+ .kitty_keyboard_pop => self.terminal.screens.active.kitty_keyboard.pop(@intCast(value)), -+ .kitty_keyboard_set => self.terminal.screens.active.kitty_keyboard.set(.set, value.flags), -+ .kitty_keyboard_set_or => self.terminal.screens.active.kitty_keyboard.set(.@"or", value.flags), -+ .kitty_keyboard_set_not => self.terminal.screens.active.kitty_keyboard.set(.not, value.flags), -+ .modify_key_format => { -+ self.terminal.flags.modify_other_keys_2 = false; -+ switch (value) { -+ .other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true, -+ else => {}, -+ } -+ }, -+ .active_status_display => self.terminal.status_display = value, -+ .decaln => try self.terminal.decaln(), -+ .full_reset => self.terminal.fullReset(), -+ .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), -+ .end_hyperlink => self.terminal.screens.active.endHyperlink(), -+ .prompt_start => { -+ self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt; -+ self.terminal.flags.shell_redraws_prompt = value.redraw; -+ }, -+ .prompt_continuation => self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation, -+ .prompt_end => self.terminal.markSemanticPrompt(.input), -+ .end_of_input => self.terminal.markSemanticPrompt(.command), -+ .end_of_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, -+ .mouse_shape => self.terminal.mouse_shape = value, -+ .color_operation => try self.colorOperation(value.op, &value.requests), -+ .kitty_color_report => try self.kittyColorOperation(value), -+ -+ // Actions that require no response and have no terminal effect -+ .dcs_hook, -+ .dcs_put, -+ .dcs_unhook, -+ .apc_start, -+ .apc_end, -+ .apc_put, -+ .bell, -+ .enquiry, -+ .request_mode, -+ .request_mode_unknown, -+ .size_report, -+ .xtversion, -+ .kitty_keyboard_query, -+ .window_title, -+ .report_pwd, -+ .show_desktop_notification, -+ .progress_report, -+ .clipboard_contents, -+ .title_push, -+ .title_pop, -+ => {}, -+ } -+ } -+ -+ fn handleDeviceStatus(self: *ResponseHandler, req: device_status.Request) !void { -+ switch (req) { -+ .operating_status => { -+ // DSR 5 - Operating status report: always report "OK" -+ try self.response_buffer.appendSlice(self.alloc, "\x1B[0n"); -+ }, -+ .cursor_position => { -+ // DSR 6 - Cursor position report (CPR) -+ const cursor = self.terminal.screens.active.cursor; -+ const x = if (self.terminal.modes.get(.origin)) -+ cursor.x -| self.terminal.scrolling_region.left -+ else -+ cursor.x; -+ const y = if (self.terminal.modes.get(.origin)) -+ cursor.y -| self.terminal.scrolling_region.top -+ else -+ cursor.y; -+ var buf: [32]u8 = undefined; -+ const resp = std.fmt.bufPrint(&buf, "\x1B[{};{}R", .{ -+ y + 1, -+ x + 1, -+ }) catch return; -+ try self.response_buffer.appendSlice(self.alloc, resp); -+ }, -+ .color_scheme => { -+ // Not supported in WASM context -+ }, -+ } -+ } -+ -+ fn handleDeviceAttributes(self: *ResponseHandler, req: ansi.DeviceAttributeReq) !void { -+ // Match main Ghostty behavior for device attribute responses -+ switch (req) { -+ .primary => { -+ // DA1 - Primary Device Attributes -+ // Report as VT220 with color support (simplified for WASM) -+ // 62 = Level 2 conformance, 22 = Color text -+ try self.response_buffer.appendSlice(self.alloc, "\x1B[?62;22c"); -+ }, -+ .secondary => { -+ // DA2 - Secondary Device Attributes -+ // Report firmware version 1.10.0 (matching main Ghostty) -+ try self.response_buffer.appendSlice(self.alloc, "\x1B[>1;10;0c"); -+ }, -+ else => { -+ // DA3 and other requests - not implemented in WASM context -+ }, -+ } -+ } -+ -+ inline fn horizontalTab(self: *ResponseHandler, count: u16) !void { -+ for (0..count) |_| { -+ const x = self.terminal.screens.active.cursor.x; -+ try self.terminal.horizontalTab(); -+ if (x == self.terminal.screens.active.cursor.x) break; -+ } -+ } -+ -+ inline fn horizontalTabBack(self: *ResponseHandler, count: u16) !void { -+ for (0..count) |_| { -+ const x = self.terminal.screens.active.cursor.x; -+ try self.terminal.horizontalTabBack(); -+ if (x == self.terminal.screens.active.cursor.x) break; -+ } -+ } -+ -+ fn setMode(self: *ResponseHandler, mode: modespkg.Mode, enabled: bool) !void { -+ self.terminal.modes.set(mode, enabled); -+ switch (mode) { -+ .autorepeat, .reverse_colors => {}, -+ .origin => self.terminal.setCursorPos(1, 1), -+ .enable_left_and_right_margin => if (!enabled) { -+ self.terminal.scrolling_region.left = 0; -+ self.terminal.scrolling_region.right = self.terminal.cols - 1; -+ }, -+ .alt_screen_legacy => try self.terminal.switchScreenMode(.@"47", enabled), -+ .alt_screen => try self.terminal.switchScreenMode(.@"1047", enabled), -+ .alt_screen_save_cursor_clear_enter => try self.terminal.switchScreenMode(.@"1049", enabled), -+ .save_cursor => if (enabled) { -+ self.terminal.saveCursor(); -+ } else { -+ try self.terminal.restoreCursor(); -+ }, -+ .enable_mode_3 => {}, -+ .@"132_column" => try self.terminal.deccolm( -+ self.terminal.screens.active.alloc, -+ if (enabled) .@"132_cols" else .@"80_cols", -+ ), -+ else => {}, -+ } -+ } -+ -+ fn colorOperation(self: *ResponseHandler, op: anytype, requests: anytype) !void { -+ _ = self; -+ _ = op; -+ _ = requests; -+ // Color operations are not supported in WASM context -+ } -+ -+ fn kittyColorOperation(self: *ResponseHandler, value: anytype) !void { -+ _ = self; -+ _ = value; -+ // Kitty color operations are not supported in WASM context -+ } -+}; -+ -+/// The stream type using our response handler -+const ResponseStream = stream.Stream(ResponseHandler); -+ -+/// Wrapper struct that owns the Terminal, stream, and RenderState. -+const TerminalWrapper = struct { -+ alloc: Allocator, -+ terminal: Terminal, -+ handler: ResponseHandler, -+ stream: ResponseStream, -+ render_state: RenderState, -+ /// Response buffer for DSR and other query responses -+ response_buffer: std.ArrayList(u8), -+ /// Track alternate screen state to detect screen switches -+ last_screen_is_alternate: bool = false, -+}; -+ -+/// C-compatible cell structure (16 bytes) -+pub const GhosttyCell = extern struct { -+ codepoint: u32, -+ fg_r: u8, -+ fg_g: u8, -+ fg_b: u8, -+ bg_r: u8, -+ bg_g: u8, -+ bg_b: u8, -+ flags: u8, -+ width: u8, -+ hyperlink_id: u16, -+ grapheme_len: u8 = 0, // Number of extra codepoints beyond first -+ _pad: u8 = 0, -+}; -+ -+/// Dirty state -+pub const GhosttyDirty = enum(u8) { -+ none = 0, -+ partial = 1, -+ full = 2, -+}; -+ -+/// C-compatible terminal configuration -+pub const GhosttyTerminalConfig = extern struct { -+ scrollback_limit: u32, -+ fg_color: u32, -+ bg_color: u32, -+ cursor_color: u32, -+ palette: [16]u32, -+}; -+ -+// ============================================================================ -+// Lifecycle -+// ============================================================================ -+ -+pub fn new(cols: c_int, rows: c_int) callconv(.c) ?*anyopaque { -+ return newWithConfig(cols, rows, null); -+} -+ -+pub fn newWithConfig( -+ cols: c_int, -+ rows: c_int, -+ config_: ?*const GhosttyTerminalConfig, -+) callconv(.c) ?*anyopaque { -+ const alloc = if (builtin.target.cpu.arch.isWasm()) -+ std.heap.wasm_allocator -+ else -+ std.heap.c_allocator; -+ -+ const wrapper = alloc.create(TerminalWrapper) catch return null; -+ -+ // Parse config or use defaults -+ const scrollback_limit: usize = if (config_) |cfg| -+ if (cfg.scrollback_limit == 0) std.math.maxInt(usize) else cfg.scrollback_limit -+ else -+ 10_000; -+ -+ // Setup terminal colors -+ var colors = Terminal.Colors.default; -+ if (config_) |cfg| { -+ if (cfg.fg_color != 0) { -+ const rgb = color.RGB{ -+ .r = @truncate((cfg.fg_color >> 16) & 0xFF), -+ .g = @truncate((cfg.fg_color >> 8) & 0xFF), -+ .b = @truncate(cfg.fg_color & 0xFF), -+ }; -+ colors.foreground = color.DynamicRGB.init(rgb); -+ } -+ if (cfg.bg_color != 0) { -+ const rgb = color.RGB{ -+ .r = @truncate((cfg.bg_color >> 16) & 0xFF), -+ .g = @truncate((cfg.bg_color >> 8) & 0xFF), -+ .b = @truncate(cfg.bg_color & 0xFF), -+ }; -+ colors.background = color.DynamicRGB.init(rgb); -+ } -+ if (cfg.cursor_color != 0) { -+ const rgb = color.RGB{ -+ .r = @truncate((cfg.cursor_color >> 16) & 0xFF), -+ .g = @truncate((cfg.cursor_color >> 8) & 0xFF), -+ .b = @truncate(cfg.cursor_color & 0xFF), -+ }; -+ colors.cursor = color.DynamicRGB.init(rgb); -+ } -+ // Apply palette colors (0 = use default) -+ for (cfg.palette, 0..) |palette_color, i| { -+ if (palette_color != 0) { -+ const rgb = color.RGB{ -+ .r = @truncate((palette_color >> 16) & 0xFF), -+ .g = @truncate((palette_color >> 8) & 0xFF), -+ .b = @truncate(palette_color & 0xFF), -+ }; -+ colors.palette.set(@intCast(i), rgb); -+ } -+ } -+ } -+ -+ wrapper.terminal = Terminal.init(alloc, .{ -+ .cols = @intCast(cols), -+ .rows = @intCast(rows), -+ .max_scrollback = scrollback_limit, -+ .colors = colors, -+ }) catch { -+ alloc.destroy(wrapper); -+ return null; -+ }; -+ -+ // Initialize response buffer -+ wrapper.response_buffer = .{}; -+ -+ // Initialize handler with references to terminal and response buffer -+ wrapper.handler = ResponseHandler.init(alloc, &wrapper.terminal, &wrapper.response_buffer); -+ -+ // Initialize stream with the handler -+ wrapper.stream = ResponseStream.init(wrapper.handler); -+ -+ wrapper.* = .{ -+ .alloc = alloc, -+ .terminal = wrapper.terminal, -+ .handler = wrapper.handler, -+ .stream = wrapper.stream, -+ .render_state = RenderState.empty, -+ .response_buffer = wrapper.response_buffer, -+ }; -+ -+ // NOTE: linefeed mode must be FALSE to match native terminal behavior -+ // When true, LF does automatic CR which breaks apps like nvim -+ wrapper.terminal.modes.set(.linefeed, false); -+ -+ // Enable grapheme clustering (mode 2027) by default for proper Unicode support. -+ // This makes Hindi, Arabic, emoji sequences, etc. render correctly by treating -+ // multi-codepoint grapheme clusters as single visual units. -+ wrapper.terminal.modes.set(.grapheme_cluster, true); -+ -+ return @ptrCast(wrapper); -+} -+ -+pub fn free(ptr: ?*anyopaque) callconv(.c) void { -+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); -+ const alloc = wrapper.alloc; -+ wrapper.stream.deinit(); -+ wrapper.response_buffer.deinit(alloc); -+ wrapper.render_state.deinit(alloc); -+ wrapper.terminal.deinit(alloc); -+ alloc.destroy(wrapper); -+} -+ -+pub fn resize(ptr: ?*anyopaque, cols: c_int, rows: c_int) callconv(.c) void { -+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); -+ wrapper.terminal.resize(wrapper.alloc, @intCast(cols), @intCast(rows)) catch return; -+} -+ -+pub fn write(ptr: ?*anyopaque, data: [*]const u8, len: usize) callconv(.c) void { -+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); -+ wrapper.stream.nextSlice(data[0..len]) catch return; -+} -+ -+// ============================================================================ -+// RenderState API - High-performance rendering -+// ============================================================================ -+ -+/// Update render state from terminal. Call once per frame. -+/// Returns dirty state: 0=none, 1=partial, 2=full -+pub fn renderStateUpdate(ptr: ?*anyopaque) callconv(.c) GhosttyDirty { -+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return .full)); -+ -+ // Detect screen buffer switch (normal <-> alternate) -+ const current_is_alternate = wrapper.terminal.screens.active_key == .alternate; -+ const screen_switched = current_is_alternate != wrapper.last_screen_is_alternate; -+ wrapper.last_screen_is_alternate = current_is_alternate; -+ -+ // When screen switches, we must fully reset the render state to avoid -+ // stale cached cell data from the previous screen buffer. -+ if (screen_switched) { -+ wrapper.render_state.deinit(wrapper.alloc); -+ wrapper.render_state = RenderState.empty; -+ } -+ -+ wrapper.render_state.update(wrapper.alloc, &wrapper.terminal) catch return .full; -+ -+ // If screen switched, always return full dirty to force complete redraw -+ if (screen_switched) { -+ return .full; -+ } -+ -+ return switch (wrapper.render_state.dirty) { -+ .false => .none, -+ .partial => .partial, -+ .full => .full, -+ }; -+} -+ -+/// Get dimensions from render state -+pub fn renderStateGetCols(ptr: ?*anyopaque) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ return @intCast(wrapper.render_state.cols); -+} -+ -+pub fn renderStateGetRows(ptr: ?*anyopaque) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ return @intCast(wrapper.render_state.rows); -+} -+ -+/// Get cursor X position -+pub fn renderStateGetCursorX(ptr: ?*anyopaque) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ return @intCast(wrapper.render_state.cursor.active.x); -+} -+ -+/// Get cursor Y position -+pub fn renderStateGetCursorY(ptr: ?*anyopaque) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ return @intCast(wrapper.render_state.cursor.active.y); -+} -+ -+/// Check if cursor is visible -+pub fn renderStateGetCursorVisible(ptr: ?*anyopaque) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ return wrapper.render_state.cursor.visible; -+} -+ -+/// Get default background color as 0xRRGGBB -+pub fn renderStateGetBgColor(ptr: ?*anyopaque) callconv(.c) u32 { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ const bg = wrapper.render_state.colors.background; -+ return (@as(u32, bg.r) << 16) | (@as(u32, bg.g) << 8) | bg.b; -+} -+ -+/// Get default foreground color as 0xRRGGBB -+pub fn renderStateGetFgColor(ptr: ?*anyopaque) callconv(.c) u32 { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0xCCCCCC)); -+ const fg = wrapper.render_state.colors.foreground; -+ return (@as(u32, fg.r) << 16) | (@as(u32, fg.g) << 8) | fg.b; -+} -+ -+/// Check if row is dirty -+pub fn renderStateIsRowDirty(ptr: ?*anyopaque, y: c_int) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return true)); -+ if (wrapper.render_state.dirty == .full) return true; -+ if (wrapper.render_state.dirty == .false) return false; -+ const y_usize: usize = @intCast(y); -+ if (y_usize >= wrapper.render_state.row_data.len) return false; -+ return wrapper.render_state.row_data.items(.dirty)[y_usize]; -+} -+ -+/// Mark render state as clean after rendering -+pub fn renderStateMarkClean(ptr: ?*anyopaque) callconv(.c) void { -+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); -+ wrapper.render_state.dirty = .false; -+ @memset(wrapper.render_state.row_data.items(.dirty), false); -+} -+ -+/// Get ALL viewport cells in one call - reads directly from terminal screen buffer. -+/// This bypasses the RenderState cache to ensure fresh data for all rows. -+/// Returns total cells written (rows * cols), or -1 on error. -+pub fn renderStateGetViewport( -+ ptr: ?*anyopaque, -+ out: [*]GhosttyCell, -+ buf_size: usize, -+) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ const rs = &wrapper.render_state; -+ const t = &wrapper.terminal; -+ const rows = rs.rows; -+ const cols = rs.cols; -+ const total: usize = @as(usize, rows) * cols; -+ -+ if (buf_size < total) return -1; -+ -+ // Read directly from terminal's active screen, bypassing RenderState cache. -+ // This ensures we always get fresh data for ALL rows, not just dirty ones. -+ const pages = &t.screens.active.pages; -+ -+ var idx: usize = 0; -+ for (0..rows) |y| { -+ // Get the row from the active viewport -+ const pin = pages.pin(.{ .active = .{ .y = @intCast(y) } }) orelse { -+ // Row doesn't exist, fill with defaults -+ for (0..cols) |_| { -+ out[idx] = .{ -+ .codepoint = 0, -+ .fg_r = rs.colors.foreground.r, -+ .fg_g = rs.colors.foreground.g, -+ .fg_b = rs.colors.foreground.b, -+ .bg_r = rs.colors.background.r, -+ .bg_g = rs.colors.background.g, -+ .bg_b = rs.colors.background.b, -+ .flags = 0, -+ .width = 1, -+ .hyperlink_id = 0, -+ }; -+ idx += 1; -+ } -+ continue; -+ }; -+ -+ const cells = pin.cells(.all); -+ const page = pin.node.data; -+ -+ for (0..cols) |x| { -+ if (x >= cells.len) { -+ // Past end of row, fill with default -+ out[idx] = .{ -+ .codepoint = 0, -+ .fg_r = rs.colors.foreground.r, -+ .fg_g = rs.colors.foreground.g, -+ .fg_b = rs.colors.foreground.b, -+ .bg_r = rs.colors.background.r, -+ .bg_g = rs.colors.background.g, -+ .bg_b = rs.colors.background.b, -+ .flags = 0, -+ .width = 1, -+ .hyperlink_id = 0, -+ }; -+ idx += 1; -+ continue; -+ } -+ -+ const cell = &cells[x]; -+ -+ // Get style from page styles (cell has style_id) -+ const sty: Style = if (cell.style_id > 0) -+ page.styles.get(page.memory, cell.style_id).* -+ else -+ .{}; -+ -+ // Resolve colors -+ const fg: color.RGB = switch (sty.fg_color) { -+ .none => rs.colors.foreground, -+ .palette => |i| rs.colors.palette[i], -+ .rgb => |rgb| rgb, -+ }; -+ const bg: color.RGB = if (sty.bg(cell, &rs.colors.palette)) |rgb| rgb else rs.colors.background; -+ -+ // Build flags -+ var flags: u8 = 0; -+ if (sty.flags.bold) flags |= 1 << 0; -+ if (sty.flags.italic) flags |= 1 << 1; -+ if (sty.flags.underline != .none) flags |= 1 << 2; -+ if (sty.flags.strikethrough) flags |= 1 << 3; -+ if (sty.flags.inverse) flags |= 1 << 4; -+ if (sty.flags.invisible) flags |= 1 << 5; -+ if (sty.flags.blink) flags |= 1 << 6; -+ if (sty.flags.faint) flags |= 1 << 7; -+ -+ // Get grapheme length if cell has grapheme data -+ const grapheme_len: u8 = if (cell.hasGrapheme()) -+ if (page.lookupGrapheme(cell)) |cps| @min(@as(u8, @intCast(cps.len)), 255) else 0 -+ else -+ 0; -+ -+ out[idx] = .{ -+ .codepoint = cell.codepoint(), -+ .fg_r = fg.r, -+ .fg_g = fg.g, -+ .fg_b = fg.b, -+ .bg_r = bg.r, -+ .bg_g = bg.g, -+ .bg_b = bg.b, -+ .flags = flags, -+ .width = switch (cell.wide) { -+ .narrow => 1, -+ .wide => 2, -+ .spacer_tail, .spacer_head => 0, -+ }, -+ .hyperlink_id = if (cell.hyperlink) 1 else 0, -+ .grapheme_len = grapheme_len, -+ }; -+ idx += 1; -+ } -+ } -+ -+ return @intCast(total); -+} -+ -+/// Get grapheme codepoints for a cell at (row, col). -+/// Returns all codepoints (including the first one) as u32 values. -+/// Returns the number of codepoints written, or -1 on error. -+pub fn renderStateGetGrapheme( -+ ptr: ?*anyopaque, -+ row: c_int, -+ col: c_int, -+ out: [*]u32, -+ buf_size: usize, -+) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ const rs = &wrapper.render_state; -+ const t = &wrapper.terminal; -+ const cols: usize = @intCast(rs.cols); -+ -+ if (row < 0 or col < 0) return -1; -+ if (@as(usize, @intCast(row)) >= rs.rows) return -1; -+ if (@as(usize, @intCast(col)) >= cols) return -1; -+ if (buf_size < 1) return -1; -+ -+ // Get the pin for this row from the terminal's active screen -+ const pages = &t.screens.active.pages; -+ const pin = pages.pin(.{ .active = .{ .y = @intCast(row) } }) orelse return -1; -+ -+ const cells = pin.cells(.all); -+ const page = pin.node.data; -+ const x: usize = @intCast(col); -+ -+ if (x >= cells.len) return -1; -+ -+ const cell = &cells[x]; -+ -+ // First codepoint is always from the cell -+ out[0] = cell.codepoint(); -+ var count: usize = 1; -+ -+ // Add extra codepoints from grapheme map if present -+ if (cell.hasGrapheme()) { -+ if (page.lookupGrapheme(cell)) |cps| { -+ for (cps) |cp| { -+ if (count >= buf_size) break; -+ out[count] = cp; -+ count += 1; -+ } -+ } -+ } -+ -+ return @intCast(count); -+} -+ -+// ============================================================================ -+// Terminal Modes (minimal set for compatibility) -+// ============================================================================ -+ -+pub fn isAlternateScreen(ptr: ?*anyopaque) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ return wrapper.terminal.screens.active_key == .alternate; -+} -+ -+pub fn hasMouseTracking(ptr: ?*anyopaque) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ return wrapper.terminal.modes.get(.mouse_event_normal) or -+ wrapper.terminal.modes.get(.mouse_event_button) or -+ wrapper.terminal.modes.get(.mouse_event_any); -+} -+ -+/// Query arbitrary terminal mode by number -+/// Returns true if mode is set, false otherwise -+pub fn getMode(ptr: ?*anyopaque, mode_num: c_int, is_ansi: bool) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ const mode = modespkg.modeFromInt(@intCast(mode_num), is_ansi) orelse return false; -+ return wrapper.terminal.modes.get(mode); -+} -+ -+// ============================================================================ -+// Scrollback API -+// ============================================================================ -+ -+/// Get the number of scrollback lines (history, not including active screen) -+pub fn getScrollbackLength(ptr: ?*anyopaque) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ const pages = &wrapper.terminal.screens.active.pages; -+ // total_rows includes both scrollback and active area -+ // We subtract rows (active area) to get just scrollback -+ if (pages.total_rows <= pages.rows) return 0; -+ return @intCast(pages.total_rows - pages.rows); -+} -+ -+/// Get a line from the scrollback buffer -+/// offset 0 = oldest line in scrollback, offset (length-1) = most recent scrollback line -+/// Returns number of cells written, or -1 on error -+pub fn getScrollbackLine( -+ ptr: ?*anyopaque, -+ offset: c_int, -+ out: [*]GhosttyCell, -+ buf_size: usize, -+) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ const rs = &wrapper.render_state; -+ const cols = rs.cols; -+ -+ if (buf_size < cols) return -1; -+ if (offset < 0) return -1; -+ -+ const scrollback_len = getScrollbackLength(ptr); -+ if (offset >= scrollback_len) return -1; -+ -+ // Get the pin for this scrollback row -+ // history point: y=0 is oldest, y=scrollback_len-1 is newest -+ const pages = &wrapper.terminal.screens.active.pages; -+ const pin = pages.pin(.{ .history = .{ .y = @intCast(offset) } }) orelse return -1; -+ -+ // Get cells for this row -+ const cells = pin.cells(.all); -+ const page = pin.node.data; -+ -+ // Fill output buffer -+ for (0..cols) |x| { -+ if (x >= cells.len) { -+ // Fill with default -+ out[x] = .{ -+ .codepoint = 0, -+ .fg_r = rs.colors.foreground.r, -+ .fg_g = rs.colors.foreground.g, -+ .fg_b = rs.colors.foreground.b, -+ .bg_r = rs.colors.background.r, -+ .bg_g = rs.colors.background.g, -+ .bg_b = rs.colors.background.b, -+ .flags = 0, -+ .width = 1, -+ .hyperlink_id = 0, -+ }; -+ continue; -+ } -+ -+ const cell = &cells[x]; -+ -+ // Get style from page styles (cell has style_id) -+ const sty: Style = if (cell.style_id > 0) -+ page.styles.get(page.memory, cell.style_id).* -+ else -+ .{}; -+ -+ // Resolve colors -+ const fg: color.RGB = switch (sty.fg_color) { -+ .none => rs.colors.foreground, -+ .palette => |i| rs.colors.palette[i], -+ .rgb => |rgb| rgb, -+ }; -+ const bg: color.RGB = if (sty.bg(cell, &rs.colors.palette)) |rgb| rgb else rs.colors.background; -+ -+ // Build flags -+ var flags: u8 = 0; -+ if (sty.flags.bold) flags |= 1 << 0; -+ if (sty.flags.italic) flags |= 1 << 1; -+ if (sty.flags.underline != .none) flags |= 1 << 2; -+ if (sty.flags.strikethrough) flags |= 1 << 3; -+ if (sty.flags.inverse) flags |= 1 << 4; -+ if (sty.flags.invisible) flags |= 1 << 5; -+ if (sty.flags.blink) flags |= 1 << 6; -+ if (sty.flags.faint) flags |= 1 << 7; -+ -+ // Get grapheme length if cell has grapheme data -+ const grapheme_len: u8 = if (cell.hasGrapheme()) -+ if (page.lookupGrapheme(cell)) |cps| @min(@as(u8, @intCast(cps.len)), 255) else 0 -+ else -+ 0; -+ -+ out[x] = .{ -+ .codepoint = cell.codepoint(), -+ .fg_r = fg.r, -+ .fg_g = fg.g, -+ .fg_b = fg.b, -+ .bg_r = bg.r, -+ .bg_g = bg.g, -+ .bg_b = bg.b, -+ .flags = flags, -+ .width = switch (cell.wide) { -+ .narrow => 1, -+ .wide => 2, -+ .spacer_tail, .spacer_head => 0, -+ }, -+ .hyperlink_id = if (cell.hyperlink) 1 else 0, -+ .grapheme_len = grapheme_len, -+ }; -+ } -+ return @intCast(cols); -+} -+ -+/// Get grapheme codepoints for a cell in the scrollback buffer. -+/// Returns all codepoints (including the first one) as u32 values. -+/// Returns the number of codepoints written, or -1 on error. -+pub fn getScrollbackGrapheme( -+ ptr: ?*anyopaque, -+ offset: c_int, -+ col: c_int, -+ out: [*]u32, -+ buf_size: usize, -+) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ const rs = &wrapper.render_state; -+ const cols: usize = @intCast(rs.cols); -+ -+ if (offset < 0 or col < 0) return -1; -+ if (@as(usize, @intCast(col)) >= cols) return -1; -+ if (buf_size < 1) return -1; -+ -+ const scrollback_len = getScrollbackLength(ptr); -+ if (offset >= scrollback_len) return -1; -+ -+ // Get the pin for this scrollback row -+ const pages = &wrapper.terminal.screens.active.pages; -+ const pin = pages.pin(.{ .history = .{ .y = @intCast(offset) } }) orelse return -1; -+ -+ const cells = pin.cells(.all); -+ const page = pin.node.data; -+ const x: usize = @intCast(col); -+ -+ if (x >= cells.len) return -1; -+ -+ const cell = &cells[x]; -+ -+ // First codepoint is always from the cell -+ out[0] = cell.codepoint(); -+ var count: usize = 1; -+ -+ // Add extra codepoints from grapheme map if present -+ if (cell.hasGrapheme()) { -+ if (page.lookupGrapheme(cell)) |cps| { -+ for (cps) |cp| { -+ if (count >= buf_size) break; -+ out[count] = cp; -+ count += 1; -+ } -+ } -+ } -+ -+ return @intCast(count); -+} -+ -+/// Check if a row is a continuation from the previous row (soft-wrapped) -+/// This matches xterm.js semantics where isWrapped indicates the row continues -+/// from the previous row, not that it wraps to the next row. -+pub fn isRowWrapped(ptr: ?*anyopaque, y: c_int) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ const pages = &wrapper.terminal.screens.active.pages; -+ -+ // Get pin for this row in active area -+ const pin = pages.pin(.{ .active = .{ .y = @intCast(y) } }) orelse return false; -+ const rac = pin.rowAndCell(); -+ -+ // wrap_continuation means this row continues from the previous row -+ return rac.row.wrap_continuation; -+} -+ -+// ============================================================================ -+// Hyperlink API -+// ============================================================================ -+ -+/// Get the hyperlink URI for a cell in the active viewport. -+/// Returns number of bytes written, 0 if no hyperlink, -1 on error. -+pub fn getHyperlinkUri( -+ ptr: ?*anyopaque, -+ row: c_int, -+ col: c_int, -+ out: [*]u8, -+ buf_size: usize, -+) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ const t = &wrapper.terminal; -+ -+ if (row < 0 or col < 0) return -1; -+ -+ // Get the pin for this row from the terminal's active screen -+ const pages = &t.screens.active.pages; -+ const pin = pages.pin(.{ .active = .{ .y = @intCast(row) } }) orelse return -1; -+ -+ const cells = pin.cells(.all); -+ const page = pin.node.data; -+ const x: usize = @intCast(col); -+ -+ if (x >= cells.len) return -1; -+ -+ const cell = &cells[x]; -+ -+ // Check if cell has a hyperlink -+ if (!cell.hyperlink) return 0; -+ -+ // Look up the hyperlink ID from the page -+ const hyperlink_id = page.lookupHyperlink(cell) orelse return 0; -+ -+ // Get the hyperlink entry from the set -+ const hyperlink_entry = page.hyperlink_set.get(page.memory, hyperlink_id); -+ -+ // Get the URI bytes from the page memory -+ const uri = hyperlink_entry.uri.slice(page.memory); -+ -+ if (uri.len == 0) return 0; -+ if (buf_size < uri.len) return -1; -+ -+ @memcpy(out[0..uri.len], uri); -+ return @intCast(uri.len); -+} -+ -+/// Get the hyperlink URI for a cell in the scrollback buffer. -+/// @param offset Scrollback line offset (0 = oldest, scrollback_len-1 = newest) -+/// @param col Column index (0-based) -+/// Returns number of bytes written, 0 if no hyperlink, -1 on error. -+pub fn getScrollbackHyperlinkUri( -+ ptr: ?*anyopaque, -+ offset: c_int, -+ col: c_int, -+ out: [*]u8, -+ buf_size: usize, -+) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ -+ if (offset < 0 or col < 0) return -1; -+ -+ const scrollback_len = getScrollbackLength(ptr); -+ if (offset >= scrollback_len) return -1; -+ -+ // Get the pin for this scrollback row -+ const pages = &wrapper.terminal.screens.active.pages; -+ const pin = pages.pin(.{ .history = .{ .y = @intCast(offset) } }) orelse return -1; -+ -+ const cells = pin.cells(.all); -+ const page = pin.node.data; -+ const x: usize = @intCast(col); -+ -+ if (x >= cells.len) return -1; -+ -+ const cell = &cells[x]; -+ -+ // Check if cell has a hyperlink -+ if (!cell.hyperlink) return 0; -+ -+ // Look up the hyperlink ID from the page -+ const hyperlink_id = page.lookupHyperlink(cell) orelse return 0; -+ -+ // Get the hyperlink entry from the set -+ const hyperlink_entry = page.hyperlink_set.get(page.memory, hyperlink_id); -+ -+ // Get the URI bytes from the page memory -+ const uri = hyperlink_entry.uri.slice(page.memory); -+ -+ if (uri.len == 0) return 0; -+ if (buf_size < uri.len) return -1; -+ -+ @memcpy(out[0..uri.len], uri); -+ return @intCast(uri.len); -+} -+ -+// ============================================================================ -+// Response API - for DSR and other terminal queries -+// ============================================================================ -+ -+/// Check if there are pending responses from the terminal -+pub fn hasResponse(ptr: ?*anyopaque) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ return wrapper.response_buffer.items.len > 0; -+} -+ -+/// Read pending responses from the terminal. -+/// Returns number of bytes written to buffer, or 0 if no responses pending. -+/// Returns -1 on error (null pointer or buffer too small). -+pub fn readResponse(ptr: ?*anyopaque, out: [*]u8, buf_size: usize) callconv(.c) c_int { -+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ const len = @min(wrapper.response_buffer.items.len, buf_size); -+ if (len == 0) return 0; -+ -+ @memcpy(out[0..len], wrapper.response_buffer.items[0..len]); -+ -+ // Remove consumed bytes from buffer -+ if (len == wrapper.response_buffer.items.len) { -+ wrapper.response_buffer.clearRetainingCapacity(); -+ } else { -+ // Shift remaining bytes to front -+ std.mem.copyForwards( -+ u8, -+ wrapper.response_buffer.items[0..], -+ wrapper.response_buffer.items[len..], -+ ); -+ wrapper.response_buffer.shrinkRetainingCapacity(wrapper.response_buffer.items.len - len); -+ } -+ -+ return @intCast(len); -+} -+ -+// ============================================================================ -+// Tests -+// ============================================================================ -+ -+test "terminal lifecycle" { -+ const term = new(80, 24); -+ defer free(term); -+ try std.testing.expect(term != null); -+ -+ _ = renderStateUpdate(term); -+ try std.testing.expectEqual(@as(c_int, 80), renderStateGetCols(term)); -+ try std.testing.expectEqual(@as(c_int, 24), renderStateGetRows(term)); -+} -+ -+test "terminal write and read via render state" { -+ const term = new(80, 24); -+ defer free(term); -+ -+ write(term, "Hello", 5); -+ _ = renderStateUpdate(term); -+ -+ var cells: [80 * 24]GhosttyCell = undefined; -+ const count = renderStateGetViewport(term, &cells, 80 * 24); -+ try std.testing.expectEqual(@as(c_int, 80 * 24), count); -+ try std.testing.expectEqual(@as(u32, 'H'), cells[0].codepoint); -+ try std.testing.expectEqual(@as(u32, 'e'), cells[1].codepoint); -+ try std.testing.expectEqual(@as(u32, 'l'), cells[2].codepoint); -+ try std.testing.expectEqual(@as(u32, 'l'), cells[3].codepoint); -+ try std.testing.expectEqual(@as(u32, 'o'), cells[4].codepoint); -+} -diff --git a/src/terminal/render.zig b/src/terminal/render.zig -index b6430ea34..10e0ef79d 100644 ---- a/src/terminal/render.zig -+++ b/src/terminal/render.zig -@@ -322,13 +322,14 @@ pub const RenderState = struct { - // Colors. - self.colors.cursor = t.colors.cursor.get(); - self.colors.palette = t.colors.palette.current; -- bg_fg: { -+ { - // Background/foreground can be unset initially which would -- // depend on "default" background/foreground. The expected use -- // case of Terminal is that the caller set their own configured -- // defaults on load so this doesn't happen. -- const bg = t.colors.background.get() orelse break :bg_fg; -- const fg = t.colors.foreground.get() orelse break :bg_fg; -+ // depend on "default" background/foreground. Use sensible defaults -+ // (black background, light gray foreground) when not explicitly set. -+ const default_bg: color.RGB = .{ .r = 0, .g = 0, .b = 0 }; -+ const default_fg: color.RGB = .{ .r = 204, .g = 204, .b = 204 }; -+ const bg = t.colors.background.get() orelse default_bg; -+ const fg = t.colors.foreground.get() orelse default_fg; - if (t.modes.get(.reverse_colors)) { - self.colors.background = fg; - self.colors.foreground = bg; diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index 53014fd..ca46809 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -3,13 +3,14 @@ set -euo pipefail echo "🔨 Building ghostty-vt.wasm..." -# Check for Zig +# Check for Zig (ghostty's build.zig pins a specific version) if ! command -v zig &> /dev/null; then echo "❌ Error: Zig not found" echo "" - echo "Install Zig 0.15.2+:" - echo " macOS: brew install zig" - echo " Linux: https://ziglang.org/download/" + echo "Use the version pinned by ghostty/build.zig (currently 0.15.2)." + echo " macOS: brew install zig (may not match)" + echo " Nix: nix develop" + echo " Manual: https://ziglang.org/download/" echo "" exit 1 fi @@ -17,39 +18,56 @@ fi ZIG_VERSION=$(zig version) echo "✓ Found Zig $ZIG_VERSION" -# Initialize/update submodule -if [ ! -d "ghostty/.git" ]; then +# Initialize submodule on first checkout (gitlink is a file, not a directory) +if [ ! -e "ghostty/.git" ]; then echo "📦 Initializing Ghostty submodule..." git submodule update --init --recursive else echo "📦 Ghostty submodule already initialized" fi -# Apply patch -echo "🔧 Applying WASM API patch..." +# Ensure submodule worktree is clean before patching (in case a previous build was interrupted) cd ghostty -git apply --check ../patches/ghostty-wasm-api.patch || { - echo "❌ Patch doesn't apply cleanly" - echo "Ghostty may have changed. Check patches/ghostty-wasm-api.patch" - exit 1 -} -git apply ../patches/ghostty-wasm-api.patch +if [ -n "$(git status --porcelain)" ]; then + echo "🧹 Submodule has leftover changes, resetting..." + git restore . + git clean -fd +fi +cd .. + +# Apply patch (optional — skip if empty/missing) +PATCH=patches/ghostty-wasm-api.patch +if [ -s "$PATCH" ]; then + echo "🔧 Applying WASM API patch..." + cd ghostty + git apply --check "../$PATCH" || { + echo "❌ Patch doesn't apply cleanly" + echo "Ghostty may have changed. Check $PATCH" + exit 1 + } + git apply "../$PATCH" + cd .. +else + echo "🔧 No patch to apply (skipping)" +fi # Build WASM echo "⚙️ Building WASM (takes ~20 seconds)..." -zig build lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall +cd ghostty +zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall +cd .. # Copy to project root -cd .. cp ghostty/zig-out/bin/ghostty-vt.wasm ./ -# Revert patch to keep submodule clean +# Revert patch & clean any new files it created so the submodule stays clean echo "🧹 Cleaning up..." cd ghostty -git apply -R ../patches/ghostty-wasm-api.patch -# Remove new files created by the patch -rm -f include/ghostty/vt/terminal.h -rm -f src/terminal/c/terminal.zig +if [ -s "../$PATCH" ]; then + git apply -R "../$PATCH" +fi +git restore . +git clean -fd cd .. SIZE=$(du -h ghostty-vt.wasm | cut -f1)