From 3501527a8b86c5fd73ad93c5045df24b1477a95f Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 5 Aug 2026 17:58:12 +0000 Subject: [PATCH 1/6] feat(core): add embedded terminal renderable --- packages/core/scripts/test-node.ts | 1 + packages/core/src/native-handle.test.ts | 15 + .../src/renderables/EmbeddedTerminal.test.ts | 183 +++++++++ .../core/src/renderables/EmbeddedTerminal.ts | 317 ++++++++++++++++ packages/core/src/renderables/index.ts | 1 + packages/core/src/zig-structs.ts | 24 ++ packages/core/src/zig.ts | 346 ++++++++++++++++++ packages/core/src/zig/buffer.zig | 26 ++ .../src/zig/embedded-terminal/compositor.zig | 166 +++++++++ .../src/zig/embedded-terminal/ghostty.zig | 1 + .../core/src/zig/embedded-terminal/main.zig | 59 ++- .../core/src/zig/embedded-terminal/tests.zig | 89 +++++ .../src/zig/embedded-terminal/unavailable.zig | 52 +++ packages/core/src/zig/handles.zig | 1 + packages/core/src/zig/lib.zig | 274 ++++++++++++++ packages/core/src/zig/tests/buffer_test.zig | 14 + packages/core/tsconfig.node-test.json | 1 + 17 files changed, 1569 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/renderables/EmbeddedTerminal.test.ts create mode 100644 packages/core/src/renderables/EmbeddedTerminal.ts create mode 100644 packages/core/src/zig/embedded-terminal/compositor.zig create mode 100644 packages/core/src/zig/embedded-terminal/unavailable.zig diff --git a/packages/core/scripts/test-node.ts b/packages/core/scripts/test-node.ts index 8c5babbfb..889a742fa 100644 --- a/packages/core/scripts/test-node.ts +++ b/packages/core/scripts/test-node.ts @@ -74,6 +74,7 @@ const emittedAllowlist = [ ".node-test/src/renderables/Diff.regression.test.js", ".node-test/src/renderables/Diff.test.js", ".node-test/src/renderables/EditBufferRenderable.test.js", + ".node-test/src/renderables/EmbeddedTerminal.test.js", ".node-test/src/renderables/Input.test.js", ".node-test/src/renderables/Select.test.js", ".node-test/src/renderables/Slider.test.js", diff --git a/packages/core/src/native-handle.test.ts b/packages/core/src/native-handle.test.ts index cd9c118f7..b5277d5e0 100644 --- a/packages/core/src/native-handle.test.ts +++ b/packages/core/src/native-handle.test.ts @@ -13,6 +13,7 @@ import { resolveRenderLib, setRenderLibPath, type OptimizedBufferHandle, + type EmbeddedTerminalHandle, type RendererHandle, type TextBufferHandle, } from "./zig.js" @@ -65,6 +66,20 @@ describe("native handles", () => { lib.destroyRenderer(renderer) }) + test("embedded terminal stale and wrong-kind handles are rejected", () => { + const lib = resolveRenderLib() + const terminal = lib.createEmbeddedTerminal({ cols: 10, rows: 2 }) + lib.destroyEmbeddedTerminal(terminal) + lib.destroyEmbeddedTerminal(terminal) + expect(() => lib.embeddedTerminalWrite(terminal, "stale")).toThrow("invalid value or handle") + + const renderer = lib.createRenderer(4, 3, { bufferedOutput: "memory" }) as RendererHandle + expect(() => lib.embeddedTerminalWrite(renderer as unknown as EmbeddedTerminalHandle, "wrong kind")).toThrow( + "invalid value or handle", + ) + lib.destroyRenderer(renderer) + }) + test("text, view, edit, editor, and syntax stale handles are rejected", () => { const lib = resolveRenderLib() diff --git a/packages/core/src/renderables/EmbeddedTerminal.test.ts b/packages/core/src/renderables/EmbeddedTerminal.test.ts new file mode 100644 index 000000000..0d5ab0294 --- /dev/null +++ b/packages/core/src/renderables/EmbeddedTerminal.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { createTestRenderer, type TestRendererSetup } from "../testing/test-renderer.js" +import { KeyEvent } from "../lib/KeyHandler.js" +import { resolveRenderLib } from "../zig.js" +import { EmbeddedTerminalRenderable } from "./EmbeddedTerminal.js" + +describe("EmbeddedTerminalRenderable", () => { + let setup: TestRendererSetup + + beforeEach(async () => { + setup = await createTestRenderer({ width: 30, height: 8 }) + }) + + afterEach(() => setup.renderer.destroy()) + + test("creates native state from the statically linked runtime", () => { + const lib = resolveRenderLib() + const handle = lib.createEmbeddedTerminal({ cols: 10, rows: 2 }) + expect(handle).toBeTruthy() + lib.destroyEmbeddedTerminal(handle) + }) + + test("renders VT output and preserves it across clean frames", async () => { + const terminal = new EmbeddedTerminalRenderable(setup.renderer, { width: 20, height: 4 }) + setup.renderer.root.add(terminal) + terminal.write("hello \x1b[1;32mworld\x1b[0m\r\nwide: 界") + + await setup.renderOnce() + const first = setup.captureCharFrame() + expect(first).toContain("hello world") + expect(first).toContain("wide: 界") + + await setup.renderOnce() + expect(setup.captureCharFrame()).toBe(first) + }) + + test("encodes keys and bracketed paste", () => { + const terminal = new EmbeddedTerminalRenderable(setup.renderer, { width: 20, height: 4 }) + setup.renderer.root.add(terminal) + terminal.write("\x1b[?2004h") + + expect(new TextDecoder().decode(terminal.encodeKey(keyEvent({ name: "enter", sequence: "\r" })))).toBe("\r") + expect(new TextDecoder().decode(terminal.encodePaste(new TextEncoder().encode("one\ntwo")))).toBe( + "\x1b[200~one\ntwo\x1b[201~", + ) + expect(new TextDecoder().decode(terminal.encodeKey(keyEvent({ name: "😀", sequence: "😀" })))).toBe("😀") + expect(new TextDecoder().decode(terminal.encodeKey(keyEvent({ name: "space", sequence: " " })))).toBe(" ") + expect( + new TextDecoder().decode(terminal.encodeKey(keyEvent({ name: "a", sequence: "A", shift: true, raw: "A" }))), + ).toBe("A") + + terminal.write("\x1b[>19u") + const longText = "x".repeat(2048) + expect(new TextDecoder().decode(terminal.encodeKey(keyEvent({ name: longText, sequence: longText })))).toBe( + longText, + ) + }) + + test("encodes no-button motion and suppresses unavailable pixel coordinates", () => { + const lib = resolveRenderLib() + const handle = lib.createEmbeddedTerminal({ cols: 20, rows: 4 }) + try { + lib.embeddedTerminalWrite(handle, "\x1b[?1003h\x1b[?1006h") + const motion = lib.embeddedTerminalEncodeMouse(handle, { + action: "motion", + x: 1, + y: 1, + }) + expect(new TextDecoder().decode(motion)).toBe("\x1b[<35;2;2M") + + lib.embeddedTerminalWrite(handle, "\x1b[?1016h") + expect( + lib.embeddedTerminalEncodeMouse(handle, { + action: "motion", + x: 1, + y: 1, + }), + ).toHaveLength(0) + } finally { + lib.destroyEmbeddedTerminal(handle) + } + }) + + test("resizes and destroys native state idempotently", async () => { + const sizes: Array<[number, number]> = [] + const terminal = new EmbeddedTerminalRenderable(setup.renderer, { + width: 10, + height: 2, + onTerminalResize: (cols, rows) => sizes.push([cols, rows]), + }) + setup.renderer.root.add(terminal) + terminal.write("abcdefghij") + await setup.renderOnce() + + terminal.width = 5 + terminal.height = 3 + await setup.renderOnce() + expect(terminal.width).toBe(5) + expect(terminal.height).toBe(3) + expect(sizes.some(([cols, rows]) => cols === 5 && rows === 3)).toBe(true) + + terminal.destroy() + terminal.destroy() + expect(terminal.isDestroyed).toBe(true) + }) + + test("rejects dimensions that cannot cross the native ABI", () => { + expect(() => new EmbeddedTerminalRenderable(setup.renderer, { cols: 0, rows: 24 })).toThrow( + "columns must be an integer between 1 and 65535", + ) + expect( + () => new EmbeddedTerminalRenderable(setup.renderer, { cols: 80, rows: 24, maxScrollback: 0x1_0000_0000 }), + ).toThrow("maxScrollback must be an integer between 0 and 4294967295") + }) + + test("cleans up focus and native state when the data callback throws", () => { + const terminal = new EmbeddedTerminalRenderable(setup.renderer, { + width: 20, + height: 4, + onData: () => { + throw new Error("write failed") + }, + }) + setup.renderer.root.add(terminal) + terminal.write("\x1b[?1004h") + + expect(() => terminal.focus()).toThrow("write failed") + expect(terminal.focused).toBe(false) + + terminal.onData = undefined + terminal.focus() + terminal.onData = () => { + throw new Error("write failed") + } + terminal.destroy() + expect(terminal.isDestroyed).toBe(true) + }) + + test("forwards Kitty key releases while focused", () => { + const output: Uint8Array[] = [] + const terminal = new EmbeddedTerminalRenderable(setup.renderer, { + width: 20, + height: 4, + onData: (data) => output.push(data), + }) + setup.renderer.root.add(terminal) + terminal.write("\x1b[>3u") + terminal.focus() + + setup.renderer.keyInput.processParsedKey({ + name: "a", + ctrl: false, + meta: false, + shift: false, + option: false, + sequence: "", + raw: "", + number: false, + eventType: "release", + source: "kitty", + code: "KeyA", + }) + + expect(new TextDecoder().decode(output.at(-1))).toBe("\x1b[97;1:3u") + }) +}) + +function keyEvent( + options: Pick[0], "name" | "sequence"> & + Partial[0]>, +): KeyEvent { + return new KeyEvent({ + ctrl: false, + meta: false, + shift: false, + option: false, + raw: options.sequence, + number: false, + eventType: "press", + source: "raw", + ...options, + }) +} diff --git a/packages/core/src/renderables/EmbeddedTerminal.ts b/packages/core/src/renderables/EmbeddedTerminal.ts new file mode 100644 index 000000000..b33f6259b --- /dev/null +++ b/packages/core/src/renderables/EmbeddedTerminal.ts @@ -0,0 +1,317 @@ +import { type RenderableOptions, Renderable } from "../Renderable.js" +import type { KeyEvent, PasteEvent } from "../lib/KeyHandler.js" +import { RGBA } from "../lib/RGBA.js" +import type { RenderContext } from "../types.js" +import type { MouseEvent } from "../renderer.js" +import type { OptimizedBuffer } from "../buffer.js" +import { resolveRenderLib, type EmbeddedTerminalHandle, type EmbeddedTerminalMouse, type RenderLib } from "../zig.js" + +export interface EmbeddedTerminalOptions extends RenderableOptions { + cols?: number + rows?: number + maxScrollback?: number + onData?: (data: Uint8Array) => void + onTerminalResize?: (cols: number, rows: number) => void +} + +const MOD_SHIFT = 1 << 0 +const MOD_CTRL = 1 << 1 +const MOD_ALT = 1 << 2 +const MOD_SUPER = 1 << 3 +const MOD_CAPS_LOCK = 1 << 4 +const MOD_NUM_LOCK = 1 << 5 + +export class EmbeddedTerminalRenderable extends Renderable { + private readonly lib: RenderLib + private handle: EmbeddedTerminalHandle | null = null + private _onData?: (data: Uint8Array) => void + private _onTerminalResize?: (cols: number, rows: number) => void + private keyreleaseHandler: ((key: KeyEvent) => void) | null = null + + constructor(ctx: RenderContext, options: EmbeddedTerminalOptions) { + const cols = options.cols ?? (typeof options.width === "number" ? options.width : 80) + const rows = options.rows ?? (typeof options.height === "number" ? options.height : 24) + super(ctx, { + ...options, + width: options.width ?? cols, + height: options.height ?? rows, + buffered: true, + }) + this._focusable = true + this._onData = options.onData + this._onTerminalResize = options.onTerminalResize + this.lib = resolveRenderLib() + + try { + this.handle = this.lib.createEmbeddedTerminal({ cols, rows, maxScrollback: options.maxScrollback }) + this.setupMouse() + } catch (error) { + this.destroy() + throw error + } + } + + public get onData(): ((data: Uint8Array) => void) | undefined { + return this._onData + } + + public set onData(value: ((data: Uint8Array) => void) | undefined) { + this._onData = value + } + + public get onTerminalResize(): ((cols: number, rows: number) => void) | undefined { + return this._onTerminalResize + } + + public set onTerminalResize(value: ((cols: number, rows: number) => void) | undefined) { + this._onTerminalResize = value + } + + public write(data: string | Uint8Array): void { + if (!this.handle) return + this.lib.embeddedTerminalWrite(this.handle, data) + try { + this.flushResponses() + } finally { + this.requestRender() + } + } + + public invalidate(): void { + if (!this.handle) return + this.lib.embeddedTerminalInvalidate(this.handle) + this.requestRender() + } + + public encodeKey(key: KeyEvent): Uint8Array { + if (!this.handle) return new Uint8Array() + const text = textualKey(key) + return this.lib.embeddedTerminalEncodeKey(this.handle, { + action: key.eventType === "release" ? "release" : key.repeated ? "repeat" : "press", + key: physicalKey(key), + mods: modifiers(key), + text, + unshiftedCodepoint: key.baseCode ?? physicalUnshiftedCodepoint(key.code), + }) + } + + public encodePaste(bytes: Uint8Array): Uint8Array { + if (!this.handle) return new Uint8Array() + return this.lib.embeddedTerminalEncodePaste(this.handle, bytes) + } + + public focus(): void { + if (this.focused) return + super.focus() + if (!this.focused) return + this.keyreleaseHandler = (key) => this.handleKeyPress(key) + this.ctx._internalKeyInput.onInternal("keyrelease", this.keyreleaseHandler) + try { + this.send(this.handle ? this.lib.embeddedTerminalEncodeFocus(this.handle, true) : new Uint8Array()) + } catch (error) { + this.removeKeyreleaseHandler() + super.blur() + throw error + } + } + + public blur(): void { + if (!this.focused) return + try { + this.send(this.handle ? this.lib.embeddedTerminalEncodeFocus(this.handle, false) : new Uint8Array()) + } catch { + // User callbacks must not prevent focus or native resource cleanup. + } finally { + this.removeKeyreleaseHandler() + super.blur() + this._ctx.setCursorPosition(0, 0, false) + } + } + + public handleKeyPress(key: KeyEvent): boolean { + const output = this.encodeKey(key) + this.send(output) + return output.byteLength > 0 + } + + public handlePaste(event: PasteEvent): void { + this.send(this.encodePaste(event.bytes)) + } + + protected onResize(width: number, height: number): void { + super.onResize(width, height) + if (!this.handle || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return + const cols = Math.min(Math.floor(width), 0xffff) + const rows = Math.min(Math.floor(height), 0xffff) + this.lib.embeddedTerminalResize(this.handle, cols, rows) + this.lib.embeddedTerminalInvalidate(this.handle) + this.flushResponses() + this._onTerminalResize?.(cols, rows) + } + + protected renderSelf(buffer: OptimizedBuffer): void { + if (!this.handle || !this.visible || this.isDestroyed) return + this.lib.embeddedTerminalCompose(this.handle, buffer.ptr, 0, 0) + if (!this.focused) return + const cursor = this.lib.embeddedTerminalCursor(this.handle) + const visible = cursor.visible && cursor.hasValue + const cursorX = cursor.wideTail && cursor.x > 0 ? cursor.x - 1 : cursor.x + this._ctx.setCursorPosition(this._screenX + cursorX + 1, this._screenY + cursor.y + 1, visible) + if (!visible) return + this._ctx.setCursorStyle({ + style: cursor.style === "bar" ? "line" : cursor.style === "underline" ? "underline" : "block", + blinking: cursor.blinking, + }) + if (cursor.color) this._ctx.setCursorColor(RGBA.fromInts(cursor.color.r, cursor.color.g, cursor.color.b, 255)) + } + + protected destroySelf(): void { + if (this.handle) { + this.lib.destroyEmbeddedTerminal(this.handle) + this.handle = null + } + this._ctx.setCursorPosition(0, 0, false) + super.destroySelf() + } + + protected onRemove(): void { + if (this.focused) this.blur() + } + + private setupMouse(): void { + const onMouseDown = this.onMouseDown + const onMouseUp = this.onMouseUp + const onMouseMove = this.onMouseMove + const onMouseDrag = this.onMouseDrag + const onMouseScroll = this.onMouseScroll + this.onMouseDown = (event) => { + this.forwardMouse(event, "press") + onMouseDown?.(event) + } + this.onMouseUp = (event) => { + this.forwardMouse(event, "release") + onMouseUp?.(event) + } + this.onMouseMove = (event) => { + this.forwardMouse(event, "motion") + onMouseMove?.(event) + } + this.onMouseDrag = (event) => { + this.forwardMouse(event, "motion") + onMouseDrag?.(event) + } + this.onMouseScroll = (event) => { + this.forwardMouse(event, "press") + onMouseScroll?.(event) + } + } + + private forwardMouse(event: MouseEvent, action: EmbeddedTerminalMouse["action"]): void { + if (!this.handle) return + if (event.type === "down" && event.button === 0) this.focus() + const output = this.lib.embeddedTerminalEncodeMouse(this.handle, { + action, + button: event.type === "move" && !event.isDragging ? undefined : mouseButton(event), + mods: modifiers(event.modifiers), + x: event.x - this._screenX, + y: event.y - this._screenY, + anyButtonPressed: event.isDragging === true || event.type === "down", + }) + if (event.type === "scroll" && output.byteLength === 0) { + const direction = event.scroll?.direction + if (direction !== "up" && direction !== "down") return + this.lib.embeddedTerminalScroll(this.handle, direction === "up" ? -3 : 3) + this.requestRender() + event.preventDefault() + event.stopPropagation() + return + } + if (output.byteLength === 0) return + event.preventDefault() + event.stopPropagation() + this.send(output) + } + + private flushResponses(): void { + if (!this.handle) return + this.send(this.lib.embeddedTerminalDrainResponses(this.handle)) + } + + private removeKeyreleaseHandler(): void { + if (!this.keyreleaseHandler) return + this.ctx._internalKeyInput.offInternal("keyrelease", this.keyreleaseHandler) + this.keyreleaseHandler = null + } + + private send(data: Uint8Array): void { + if (data.byteLength > 0) this._onData?.(data) + } +} + +function modifiers(input: { + shift?: boolean + ctrl?: boolean + alt?: boolean + meta?: boolean + option?: boolean + super?: boolean + capsLock?: boolean + numLock?: boolean +}) { + let value = 0 + if (input.shift) value |= MOD_SHIFT + if (input.ctrl) value |= MOD_CTRL + if (input.alt || input.meta || input.option) value |= MOD_ALT + if (input.super) value |= MOD_SUPER + if (input.capsLock) value |= MOD_CAPS_LOCK + if (input.numLock) value |= MOD_NUM_LOCK + return value +} + +function physicalKey(key: KeyEvent) { + if (key.code) return key.code + return ( + { + backspace: "Backspace", + enter: "Enter", + return: "Enter", + space: "Space", + tab: "Tab", + delete: "Delete", + end: "End", + home: "Home", + insert: "Insert", + pagedown: "PageDown", + pageup: "PageUp", + down: "ArrowDown", + left: "ArrowLeft", + right: "ArrowRight", + up: "ArrowUp", + escape: "Escape", + }[key.name.toLowerCase()] ?? "" + ) +} + +function textualKey(key: KeyEvent) { + if (key.sequence.length > 0 && !/[\p{Cc}]/u.test(key.sequence)) return key.sequence + if (key.name === "space") return " " + if (key.name.length === 0 || /[\p{Cc}]/u.test(key.name)) return + if ([...key.name].length === 1 || /[^\x00-\x7f]/.test(key.name)) return key.name +} + +function physicalUnshiftedCodepoint(code: string | undefined) { + if (code?.startsWith("Key") && code.length === 4) return code.charCodeAt(3) + 32 + if (code?.startsWith("Digit") && code.length === 6) return code.charCodeAt(5) + return 0 +} + +function mouseButton(event: MouseEvent): EmbeddedTerminalMouse["button"] { + if (event.type === "scroll") { + if (event.scroll?.direction === "up") return "four" + if (event.scroll?.direction === "down") return "five" + if (event.scroll?.direction === "left") return "six" + if (event.scroll?.direction === "right") return "seven" + return + } + return ({ 0: "left", 1: "middle", 2: "right", 4: "four", 5: "five" } as const)[event.button] +} diff --git a/packages/core/src/renderables/index.ts b/packages/core/src/renderables/index.ts index eb21c8c84..cf77bf80e 100644 --- a/packages/core/src/renderables/index.ts +++ b/packages/core/src/renderables/index.ts @@ -6,6 +6,7 @@ export * from "./composition/VRenderable.js" export * from "./composition/vnode.js" export * from "./Diff.js" export * from "./EditBufferRenderable.js" +export * from "./EmbeddedTerminal.js" export * from "./FrameBuffer.js" export * from "./Input.js" export * from "./Image.js" diff --git a/packages/core/src/zig-structs.ts b/packages/core/src/zig-structs.ts index 5606d6991..536d3b113 100644 --- a/packages/core/src/zig-structs.ts +++ b/packages/core/src/zig-structs.ts @@ -192,6 +192,30 @@ export const CursorStateStruct = defineStruct([ ["a", "f32"], ]) +export const EmbeddedTerminalComposeResultStruct = defineStruct([ + ["rows", "u32"], + ["cells", "u32"], + ["dirty", "u8"], + ["padding0", "u8"], + ["padding1", "u8"], + ["padding2", "u8"], +]) + +export const EmbeddedTerminalCursorStruct = defineStruct([ + ["x", "u16"], + ["y", "u16"], + ["hasValue", "bool_u8"], + ["visible", "bool_u8"], + ["blinking", "bool_u8"], + ["wideTail", "bool_u8"], + ["style", "u8"], + ["colorHasValue", "bool_u8"], + ["colorR", "u8"], + ["colorG", "u8"], + ["colorB", "u8"], + ["padding", "u8"], +]) + export const CursorStyleOptionsStruct = defineStruct([ ["style", "u8", { default: 255 }], ["blinking", "u8", { default: 255 }], diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index dfa4d35b0..802450fa7 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -48,6 +48,8 @@ import { LineInfoStruct, MeasureResultStruct, CursorStateStruct, + EmbeddedTerminalComposeResultStruct, + EmbeddedTerminalCursorStruct, CursorStyleOptionsStruct, GridDrawOptionsStruct, NativeSpanFeedOptionsStruct, @@ -117,6 +119,49 @@ export type EventSinkHandle = NativeHandle<"event_sink"> export type AudioEngineHandle = NativeHandle<"audio_engine"> export type NativeRenderableHandle = NativeHandle<"native_renderable"> export type ImageHandle = NativeHandle<"image"> +export type EmbeddedTerminalHandle = NativeHandle<"embedded_terminal"> + +export type EmbeddedTerminalDirty = "clean" | "partial" | "full" +export type EmbeddedTerminalCursorStyle = "bar" | "block" | "underline" | "block-hollow" +export type EmbeddedTerminalKeyAction = "release" | "press" | "repeat" +export type EmbeddedTerminalMouseAction = "press" | "release" | "motion" +export type EmbeddedTerminalMouseButton = "unknown" | "left" | "right" | "middle" | "four" | "five" | "six" | "seven" + +export type EmbeddedTerminalCursor = { + x: number + y: number + hasValue: boolean + visible: boolean + blinking: boolean + wideTail: boolean + style: EmbeddedTerminalCursorStyle + color?: { r: number; g: number; b: number } +} + +export type EmbeddedTerminalComposeResult = { + rows: number + cells: number + dirty: EmbeddedTerminalDirty +} + +export type EmbeddedTerminalKey = { + action?: EmbeddedTerminalKeyAction + key?: string + mods?: number + consumedMods?: number + composing?: boolean + text?: string + unshiftedCodepoint?: number +} + +export type EmbeddedTerminalMouse = { + action: EmbeddedTerminalMouseAction + button?: EmbeddedTerminalMouseButton + mods?: number + x: number + y: number + anyButtonPressed?: boolean +} let targetLibPath: string | undefined let targetLibError: Error | undefined @@ -220,6 +265,47 @@ function ptrOrNull(value: ArrayBufferView): Pointer | null { return value.byteLength === 0 ? null : ptr(value) } +const EMBEDDED_TERMINAL_ERRORS: Record = { + [-1]: "invalid value or handle", + [-2]: "out of memory", + [-3]: "embedded terminal support is unavailable", + [-4]: "output buffer is too small", + [-5]: "processing failed", +} + +function embeddedTerminalResult(status: number, operation: string) { + if (status >= 0) return status + throw new Error(`Embedded terminal ${operation} failed: ${EMBEDDED_TERMINAL_ERRORS[status] ?? `status ${status}`}`) +} + +function embeddedTerminalDimension(value: number, name: string) { + if (!Number.isInteger(value) || value < 1 || value > 0xffff) { + throw new RangeError(`Embedded terminal ${name} must be an integer between 1 and 65535`) + } + return value +} + +function embeddedTerminalScrollback(value: number) { + if (!Number.isInteger(value) || value < 0 || value > MAX_FFI_U32) { + throw new RangeError(`Embedded terminal maxScrollback must be an integer between 0 and ${MAX_FFI_U32}`) + } + return value +} + +function embeddedTerminalI32(value: number, name: string) { + if (!Number.isInteger(value) || value < -0x8000_0000 || value > 0x7fff_ffff) { + throw new RangeError(`Embedded terminal ${name} must be a signed 32-bit integer`) + } + return value +} + +function embeddedTerminalF32(value: number, name: string) { + if (!Number.isFinite(value) || Math.abs(value) > 3.4028234663852886e38) { + throw new RangeError(`Embedded terminal ${name} must be a finite 32-bit float`) + } + return value +} + function rgbaPtr(value: RGBA): Pointer { return ptr(value.buffer) } @@ -268,6 +354,58 @@ function getOpenTUILib(libPath?: string) { args: ["u32", "u32", "u32"], returns: "bool", }, + createEmbeddedTerminal: { + args: ["u16", "u16", "u32", "ptr"], + returns: "i32", + }, + destroyEmbeddedTerminal: { + args: ["u32"], + returns: "void", + }, + embeddedTerminalWrite: { + args: ["u32", "ptr", "u32"], + returns: "i32", + }, + embeddedTerminalResize: { + args: ["u32", "u16", "u16"], + returns: "i32", + }, + embeddedTerminalInvalidate: { + args: ["u32"], + returns: "i32", + }, + embeddedTerminalScroll: { + args: ["u32", "i32"], + returns: "i32", + }, + embeddedTerminalCompose: { + args: ["u32", "u32", "i32", "i32", "ptr"], + returns: "i32", + }, + embeddedTerminalCursor: { + args: ["u32", "ptr"], + returns: "i32", + }, + embeddedTerminalEncodeKey: { + args: ["u32", "u8", "ptr", "u32", "u16", "u16", "u8", "ptr", "u32", "u32", "ptr", "u32", "ptr"], + returns: "i32", + }, + embeddedTerminalEncodeMouse: { + args: ["u32", "u8", "i8", "u16", "f32", "f32", "u8", "ptr", "u32"], + returns: "i32", + }, + embeddedTerminalEncodePaste: { + args: ["u32", "ptr", "u32", "ptr", "u32"], + returns: "i32", + }, + embeddedTerminalEncodeFocus: { + args: ["u32", "u8", "ptr", "u32"], + returns: "i32", + }, + embeddedTerminalDrainResponses: { + args: ["u32", "ptr", "u32"], + returns: "i32", + }, // Renderer management createRenderer: { args: ["u32", "u32", "u8", "u8", "ptr"], @@ -2754,6 +2892,24 @@ export interface RenderLib extends AudioEngineLib { kind: NativeMeasureTargetKind, target: NativeMeasureTargetHandle | 0, ) => boolean + createEmbeddedTerminal: (options: { cols: number; rows: number; maxScrollback?: number }) => EmbeddedTerminalHandle + destroyEmbeddedTerminal: (handle: EmbeddedTerminalHandle) => void + embeddedTerminalWrite: (handle: EmbeddedTerminalHandle, data: string | Uint8Array) => void + embeddedTerminalResize: (handle: EmbeddedTerminalHandle, cols: number, rows: number) => void + embeddedTerminalInvalidate: (handle: EmbeddedTerminalHandle) => void + embeddedTerminalScroll: (handle: EmbeddedTerminalHandle, delta: number) => void + embeddedTerminalCompose: ( + handle: EmbeddedTerminalHandle, + target: OptimizedBufferHandle, + x: number, + y: number, + ) => EmbeddedTerminalComposeResult + embeddedTerminalCursor: (handle: EmbeddedTerminalHandle) => EmbeddedTerminalCursor + embeddedTerminalEncodeKey: (handle: EmbeddedTerminalHandle, key: EmbeddedTerminalKey) => Uint8Array + embeddedTerminalEncodeMouse: (handle: EmbeddedTerminalHandle, mouse: EmbeddedTerminalMouse) => Uint8Array + embeddedTerminalEncodePaste: (handle: EmbeddedTerminalHandle, input: Uint8Array) => Uint8Array + embeddedTerminalEncodeFocus: (handle: EmbeddedTerminalHandle, focused: boolean) => Uint8Array + embeddedTerminalDrainResponses: (handle: EmbeddedTerminalHandle) => Uint8Array onNativeEvent: (name: string, handler: (data: ArrayBuffer) => void) => void onceNativeEvent: (name: string, handler: (data: ArrayBuffer) => void) => void offNativeEvent: (name: string, handler: (data: ArrayBuffer) => void) => void @@ -2786,6 +2942,8 @@ class FFIRenderLib implements RenderLib { ...allocStruct(MeasureResultStruct), result: { lineCount: 0, widthColsMax: 0 } as MeasureResult, }, + embeddedTerminalCompose: allocStruct(EmbeddedTerminalComposeResultStruct), + embeddedTerminalCursor: allocStruct(EmbeddedTerminalCursorStruct), audioStreamStats: { ...allocStruct(AudioStreamStatsStruct), result: { @@ -2838,6 +2996,194 @@ class FFIRenderLib implements RenderLib { return Boolean(this.opentui.symbols.nativeRenderableSetMeasureTarget(handle, kind, target)) } + public createEmbeddedTerminal(options: { + cols: number + rows: number + maxScrollback?: number + }): EmbeddedTerminalHandle { + const cols = embeddedTerminalDimension(options.cols, "columns") + const rows = embeddedTerminalDimension(options.rows, "rows") + const maxScrollback = embeddedTerminalScrollback(options.maxScrollback ?? 10_000) + const out = new Uint32Array(1) + embeddedTerminalResult(this.opentui.symbols.createEmbeddedTerminal(cols, rows, maxScrollback, out), "creation") + if (!out[0]) throw new Error("Embedded terminal creation returned an invalid handle") + return out[0] as EmbeddedTerminalHandle + } + + public destroyEmbeddedTerminal(handle: EmbeddedTerminalHandle): void { + this.opentui.symbols.destroyEmbeddedTerminal(handle) + } + + public embeddedTerminalWrite(handle: EmbeddedTerminalHandle, data: string | Uint8Array): void { + const bytes = typeof data === "string" ? this.encoder.encode(data) : data + const length = toSafeFFIU32Length(bytes.byteLength, "Embedded terminal write length") + embeddedTerminalResult( + this.opentui.symbols.embeddedTerminalWrite(handle, length === 0 ? null : bytes, length), + "write", + ) + } + + public embeddedTerminalResize(handle: EmbeddedTerminalHandle, cols: number, rows: number): void { + embeddedTerminalResult( + this.opentui.symbols.embeddedTerminalResize( + handle, + embeddedTerminalDimension(cols, "columns"), + embeddedTerminalDimension(rows, "rows"), + ), + "resize", + ) + } + + public embeddedTerminalInvalidate(handle: EmbeddedTerminalHandle): void { + embeddedTerminalResult(this.opentui.symbols.embeddedTerminalInvalidate(handle), "invalidation") + } + + public embeddedTerminalScroll(handle: EmbeddedTerminalHandle, delta: number): void { + embeddedTerminalResult( + this.opentui.symbols.embeddedTerminalScroll(handle, embeddedTerminalI32(delta, "scroll delta")), + "scroll", + ) + } + + public embeddedTerminalCompose( + handle: EmbeddedTerminalHandle, + target: OptimizedBufferHandle, + x: number, + y: number, + ): EmbeddedTerminalComposeResult { + const storage = this.ffiStructStorage.embeddedTerminalCompose + embeddedTerminalResult( + this.opentui.symbols.embeddedTerminalCompose( + handle, + target, + embeddedTerminalI32(x, "composition x"), + embeddedTerminalI32(y, "composition y"), + storage.buffer, + ), + "compose", + ) + const result = EmbeddedTerminalComposeResultStruct.unpack(storage.buffer) + return { + rows: result.rows, + cells: result.cells, + dirty: (["clean", "partial", "full"] as const)[result.dirty] ?? "full", + } + } + + public embeddedTerminalCursor(handle: EmbeddedTerminalHandle): EmbeddedTerminalCursor { + const storage = this.ffiStructStorage.embeddedTerminalCursor + embeddedTerminalResult(this.opentui.symbols.embeddedTerminalCursor(handle, storage.buffer), "cursor query") + const result = EmbeddedTerminalCursorStruct.unpack(storage.buffer) + return { + x: result.x, + y: result.y, + hasValue: result.hasValue, + visible: result.visible, + blinking: result.blinking, + wideTail: result.wideTail, + style: (["bar", "block", "underline", "block-hollow"] as const)[result.style] ?? "block", + ...(result.colorHasValue ? { color: { r: result.colorR, g: result.colorG, b: result.colorB } } : {}), + } + } + + public embeddedTerminalEncodeKey(handle: EmbeddedTerminalHandle, key: EmbeddedTerminalKey): Uint8Array { + const keyCode = key.key ? this.encoder.encode(key.key) : new Uint8Array() + const keyCodeLength = toSafeFFIU32Length(keyCode.byteLength, "Embedded terminal physical key length") + const text = key.text ? this.encoder.encode(key.text) : new Uint8Array() + const textLength = toSafeFFIU32Length(text.byteLength, "Embedded terminal key text length") + const required = new Uint32Array(1) + const encode = (output: Uint8Array) => + this.opentui.symbols.embeddedTerminalEncodeKey( + handle, + { release: 0, press: 1, repeat: 2 }[key.action ?? "press"], + keyCodeLength === 0 ? null : keyCode, + keyCodeLength, + key.mods ?? 0, + key.consumedMods ?? 0, + key.composing ? 1 : 0, + textLength === 0 ? null : text, + textLength, + key.unshiftedCodepoint ?? 0, + output, + output.byteLength, + required, + ) + const initial = new Uint8Array(Math.max(64, textLength)) + const status = encode(initial) + if (status >= 0) return initial.slice(0, status) + if (status !== -4 || required[0] <= initial.byteLength) embeddedTerminalResult(status, "key encoding") + const output = new Uint8Array(required[0]) + const length = embeddedTerminalResult(encode(output), "key encoding") + return output.slice(0, length) + } + + public embeddedTerminalEncodeMouse(handle: EmbeddedTerminalHandle, mouse: EmbeddedTerminalMouse): Uint8Array { + const output = new Uint8Array(128) + const length = embeddedTerminalResult( + this.opentui.symbols.embeddedTerminalEncodeMouse( + handle, + { press: 0, release: 1, motion: 2 }[mouse.action], + mouse.button + ? { unknown: 0, left: 1, right: 2, middle: 3, four: 4, five: 5, six: 6, seven: 7 }[mouse.button] + : -1, + mouse.mods ?? 0, + embeddedTerminalF32(mouse.x, "mouse x"), + embeddedTerminalF32(mouse.y, "mouse y"), + mouse.anyButtonPressed ? 1 : 0, + output, + output.byteLength, + ), + "mouse encoding", + ) + return output.slice(0, length) + } + + public embeddedTerminalEncodePaste(handle: EmbeddedTerminalHandle, input: Uint8Array): Uint8Array { + const inputLength = toSafeFFIU32Length(input.byteLength, "Embedded terminal paste length") + const outputLength = toSafeFFIU32Length(inputLength + 16, "Embedded terminal paste output length") + const output = new Uint8Array(outputLength) + const length = embeddedTerminalResult( + this.opentui.symbols.embeddedTerminalEncodePaste( + handle, + inputLength === 0 ? null : input, + inputLength, + output, + output.byteLength, + ), + "paste encoding", + ) + return output.slice(0, length) + } + + public embeddedTerminalEncodeFocus(handle: EmbeddedTerminalHandle, focused: boolean): Uint8Array { + const output = new Uint8Array(16) + const length = embeddedTerminalResult( + this.opentui.symbols.embeddedTerminalEncodeFocus(handle, focused ? 1 : 0, output, output.byteLength), + "focus encoding", + ) + return output.slice(0, length) + } + + public embeddedTerminalDrainResponses(handle: EmbeddedTerminalHandle): Uint8Array { + const chunks: Uint8Array[] = [] + while (true) { + const output = new Uint8Array(64 * 1024) + const length = embeddedTerminalResult( + this.opentui.symbols.embeddedTerminalDrainResponses(handle, output, output.byteLength), + "response drain", + ) + if (length > 0) chunks.push(output.slice(0, length)) + if (length < output.byteLength) break + } + const result = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.byteLength, 0)) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.byteLength + } + return result + } + constructor(libPath?: string) { this.opentui = getOpenTUILib(libPath) this.imageRetainIccCache() diff --git a/packages/core/src/zig/buffer.zig b/packages/core/src/zig/buffer.zig index 72cc290a0..82bf03acb 100644 --- a/packages/core/src/zig/buffer.zig +++ b/packages/core/src/zig/buffer.zig @@ -1230,6 +1230,32 @@ pub const OptimizedBuffer = struct { return self.drawVisibleText(text, x, y, fg, bg, attributes); } + /// Draw one already-segmented grapheme with an authoritative terminal-cell width. + pub fn drawGrapheme( + self: *OptimizedBuffer, + grapheme_bytes: []const u8, + cell_width: u8, + x: u32, + y: u32, + fg: RGBA, + bg: RGBA, + attributes: u32, + ) BufferError!void { + if (grapheme_bytes.len == 0 or cell_width == 0 or x >= self.width or y >= self.height) return; + if (x + cell_width > self.width) return; + for (0..cell_width) |offset| { + if (!self.isPointInScissor(@intCast(x + offset), @intCast(y))) return; + } + + const encoded_char: u32 = if (grapheme_bytes.len == 1 and cell_width == 1 and grapheme_bytes[0] >= 32) + grapheme_bytes[0] + else blk: { + const gid = self.pool.alloc(grapheme_bytes) catch return BufferError.OutOfMemory; + break :blk gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, cell_width); + }; + self.set(x, y, makeCell(encoded_char, fg, bg, attributes)); + } + fn drawVisibleText( self: *OptimizedBuffer, text: []const u8, diff --git a/packages/core/src/zig/embedded-terminal/compositor.zig b/packages/core/src/zig/embedded-terminal/compositor.zig new file mode 100644 index 000000000..1be87719b --- /dev/null +++ b/packages/core/src/zig/embedded-terminal/compositor.zig @@ -0,0 +1,166 @@ +const std = @import("std"); +const ansi = @import("../ansi.zig"); +const buffer = @import("../buffer.zig"); +const ghostty = @import("ghostty.zig"); + +pub const Error = std.mem.Allocator.Error || buffer.BufferError; + +pub const Result = struct { + dirty: ghostty.RenderState.Dirty, + rows: u32, + cells: u32, +}; + +pub fn compose( + allocator: std.mem.Allocator, + state: *ghostty.RenderState, + target: *buffer.OptimizedBuffer, + origin_x: i32, + origin_y: i32, +) Error!Result { + const dirty = state.dirty; + if (dirty == .false) return .{ .dirty = dirty, .rows = 0, .cells = 0 }; + + const rows = state.row_data.slice(); + const row_dirty = rows.items(.dirty); + const row_cells = rows.items(.cells); + var rows_drawn: u32 = 0; + var cells_drawn: u32 = 0; + + for (0..state.rows) |y| { + if (dirty == .partial and !row_dirty[y]) continue; + + const dest_y = origin_y + @as(i32, @intCast(y)); + if (dest_y >= 0 and dest_y < target.getHeight()) { + clearRow(target, origin_x, @intCast(dest_y), state.cols, state.colors.foreground, state.colors.background); + try composeRow( + allocator, + row_cells[y].slice(), + target, + origin_x, + @intCast(dest_y), + &state.colors, + &cells_drawn, + ); + } + + row_dirty[y] = false; + rows_drawn += 1; + } + + state.dirty = .false; + return .{ .dirty = dirty, .rows = rows_drawn, .cells = cells_drawn }; +} + +fn clearRow(target: *buffer.OptimizedBuffer, origin_x: i32, y: u32, cols: u16, foreground: anytype, background: anytype) void { + const fg = color(foreground); + const bg = color(background); + var x: u32 = 0; + while (x < target.getWidth()) : (x += 1) { + const source_x = @as(i32, @intCast(x)) - origin_x; + if (source_x < 0 or source_x >= cols) continue; + target.set(x, y, .{ + .char = buffer.DEFAULT_SPACE_CHAR, + .fg = fg, + .bg = bg, + .attributes = 0, + }); + } +} + +fn composeRow( + allocator: std.mem.Allocator, + cells: anytype, + target: *buffer.OptimizedBuffer, + origin_x: i32, + dest_y: u32, + colors: *const ghostty.RenderState.Colors, + cells_drawn: *u32, +) Error!void { + const raw_items = cells.items(.raw); + const graphemes = cells.items(.grapheme); + const styles = cells.items(.style); + + cell_loop: for (raw_items, 0..) |raw, x| { + const dest_x = origin_x + @as(i32, @intCast(x)); + if (dest_x < 0 or dest_x >= target.getWidth()) continue; + if (raw.wide == .spacer_tail or raw.wide == .spacer_head) continue; + + const grapheme: []const u21 = if (raw.hasGrapheme()) graphemes[x] else &.{}; + const style = if (raw.hasStyling()) styles[x] else @TypeOf(styles[x]){}; + var fg = style.fg(.{ .default = colors.foreground, .palette = &colors.palette }); + var bg = style.bg(&raw, &colors.palette) orelse colors.background; + if (style.flags.inverse) std.mem.swap(@TypeOf(fg), &fg, &bg); + + var stack: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&stack); + encodeCodepoint(&writer, raw.codepoint()) catch { + try drawAllocated(allocator, target, raw, grapheme, @intCast(dest_x), dest_y, fg, bg, style); + cells_drawn.* += 1; + continue :cell_loop; + }; + for (grapheme) |codepoint| encodeCodepoint(&writer, codepoint) catch { + try drawAllocated(allocator, target, raw, grapheme, @intCast(dest_x), dest_y, fg, bg, style); + cells_drawn.* += 1; + continue :cell_loop; + }; + + try draw(target, writer.buffered(), raw.gridWidth(), @intCast(dest_x), dest_y, fg, bg, style); + cells_drawn.* += 1; + } +} + +fn drawAllocated( + allocator: std.mem.Allocator, + target: *buffer.OptimizedBuffer, + raw: anytype, + grapheme: []const u21, + x: u32, + y: u32, + foreground: anytype, + background: anytype, + style: anytype, +) Error!void { + var output: std.Io.Writer.Allocating = .init(allocator); + defer output.deinit(); + encodeCodepoint(&output.writer, raw.codepoint()) catch return error.OutOfMemory; + for (grapheme) |codepoint| encodeCodepoint(&output.writer, codepoint) catch return error.OutOfMemory; + try draw(target, output.written(), raw.gridWidth(), x, y, foreground, background, style); +} + +fn encodeCodepoint(writer: *std.Io.Writer, codepoint: u21) std.Io.Writer.Error!void { + if (codepoint == 0) return; + var bytes: [4]u8 = undefined; + const len = std.unicode.utf8Encode(codepoint, &bytes) catch return; + try writer.writeAll(bytes[0..len]); +} + +fn draw(target: *buffer.OptimizedBuffer, text: []const u8, cell_width: u8, x: u32, y: u32, foreground: anytype, background: anytype, style: anytype) buffer.BufferError!void { + const cell_attributes = attributes(style); + if (text.len == 0 or style.flags.invisible) { + target.set(x, y, .{ + .char = buffer.DEFAULT_SPACE_CHAR, + .fg = color(foreground), + .bg = color(background), + .attributes = cell_attributes, + }); + return; + } + try target.drawGrapheme(text, cell_width, x, y, color(foreground), color(background), cell_attributes); +} + +fn color(value: anytype) buffer.RGBA { + return ansi.rgbColor(value.r, value.g, value.b, 255); +} + +fn attributes(style: anytype) u32 { + var value: u32 = 0; + if (style.flags.bold) value |= ansi.TextAttributes.BOLD; + if (style.flags.faint) value |= ansi.TextAttributes.DIM; + if (style.flags.italic) value |= ansi.TextAttributes.ITALIC; + if (style.flags.underline != .none) value |= ansi.TextAttributes.UNDERLINE; + if (style.flags.blink) value |= ansi.TextAttributes.BLINK; + if (style.flags.invisible) value |= ansi.TextAttributes.HIDDEN; + if (style.flags.strikethrough) value |= ansi.TextAttributes.STRIKETHROUGH; + return value; +} diff --git a/packages/core/src/zig/embedded-terminal/ghostty.zig b/packages/core/src/zig/embedded-terminal/ghostty.zig index 8c2ca3ead..8b46faa4f 100644 --- a/packages/core/src/zig/embedded-terminal/ghostty.zig +++ b/packages/core/src/zig/embedded-terminal/ghostty.zig @@ -3,6 +3,7 @@ const vt = @import("../ghostty-vt.zig").vt; pub const Terminal = vt.Terminal; pub const TerminalStream = vt.TerminalStream; pub const Coordinate = vt.Coordinate; +pub const RenderState = vt.RenderState; pub const Key = struct { action: vt.input.KeyAction = .press, diff --git a/packages/core/src/zig/embedded-terminal/main.zig b/packages/core/src/zig/embedded-terminal/main.zig index 3e17d8d3f..129637d20 100644 --- a/packages/core/src/zig/embedded-terminal/main.zig +++ b/packages/core/src/zig/embedded-terminal/main.zig @@ -1,11 +1,26 @@ const std = @import("std"); +const buffer = @import("../buffer.zig"); +const compositor = @import("compositor.zig"); const ghostty = @import("ghostty.zig"); pub const Error = error{ InvalidValue, ProcessingFailed, ResponseOverflow, -} || std.mem.Allocator.Error; +} || std.mem.Allocator.Error || buffer.BufferError; + +pub const ComposeResult = compositor.Result; + +pub const Cursor = struct { + x: u16 = 0, + y: u16 = 0, + has_value: bool = false, + visible: bool = false, + blinking: bool = false, + wide_tail: bool = false, + style: u8 = 1, + color: ?struct { r: u8, g: u8, b: u8 } = null, +}; pub const Options = struct { cols: u16, @@ -19,11 +34,13 @@ pub const EmbeddedTerminal = struct { allocator: std.mem.Allocator, terminal: ghostty.Terminal, stream: ghostty.TerminalStream, + render_state: ghostty.RenderState = .empty, cols: u16, rows: u16, responses: std.ArrayListUnmanaged(u8) = .empty, response_error: ?Error = null, mouse_last_cell: ?ghostty.Coordinate = null, + force_redraw: bool = true, pub fn init(io: std.Io, allocator: std.mem.Allocator, options: Options) Error!*EmbeddedTerminal { if (options.cols == 0 or options.rows == 0) return error.InvalidValue; @@ -53,6 +70,7 @@ pub const EmbeddedTerminal = struct { pub fn deinit(self: *EmbeddedTerminal) void { const allocator = self.allocator; self.stream.deinit(); + self.render_state.deinit(allocator); self.terminal.deinit(allocator); self.responses.deinit(allocator); allocator.destroy(self); @@ -79,6 +97,45 @@ pub const EmbeddedTerminal = struct { self.terminal.scrollViewport(.{ .delta = delta }); } + pub fn invalidate(self: *EmbeddedTerminal) void { + self.force_redraw = true; + } + + pub fn compose(self: *EmbeddedTerminal, target: *buffer.OptimizedBuffer, x: i32, y: i32) Error!ComposeResult { + self.render_state.update(self.allocator, &self.terminal) catch |err| { + self.render_state.deinit(self.allocator); + self.render_state = .empty; + self.force_redraw = true; + return err; + }; + if (self.force_redraw) { + self.render_state.dirty = .full; + self.force_redraw = false; + } + return compositor.compose(self.allocator, &self.render_state, target, x, y); + } + + pub fn cursor(self: *EmbeddedTerminal) Cursor { + const state = self.render_state.cursor; + const viewport = state.viewport orelse return .{ .visible = state.visible }; + const cursor_color = self.render_state.colors.cursor orelse self.render_state.colors.foreground; + return .{ + .x = viewport.x, + .y = viewport.y, + .has_value = true, + .visible = state.visible, + .blinking = state.blinking, + .wide_tail = viewport.wide_tail, + .style = switch (state.visual_style) { + .bar => 0, + .block => 1, + .underline => 2, + .block_hollow => 3, + }, + .color = .{ .r = cursor_color.r, .g = cursor_color.g, .b = cursor_color.b }, + }; + } + pub fn encodeKey(self: *EmbeddedTerminal, key: ghostty.Key) Error![]u8 { var output: std.Io.Writer.Allocating = .init(self.allocator); errdefer output.deinit(); diff --git a/packages/core/src/zig/embedded-terminal/tests.zig b/packages/core/src/zig/embedded-terminal/tests.zig index 678a99f8c..0292f9aa2 100644 --- a/packages/core/src/zig/embedded-terminal/tests.zig +++ b/packages/core/src/zig/embedded-terminal/tests.zig @@ -1,7 +1,84 @@ const std = @import("std"); +const ansi = @import("../ansi.zig"); +const buffer = @import("../buffer.zig"); +const gp = @import("../grapheme.zig"); const EmbeddedTerminal = @import("main.zig").EmbeddedTerminal; const ghostty = @import("ghostty.zig"); +test "embedded terminal composes dirty rows into an OptimizedBuffer" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + var target = try buffer.OptimizedBuffer.init(std.testing.allocator, 12, 4, .{ .pool = pool }); + defer target.deinit(); + target.clear(ansi.rgbColor(0, 0, 0, 255), null); + + const terminal = try EmbeddedTerminal.init(std.testing.allocator, .{ .cols = 8, .rows = 2 }); + defer terminal.deinit(); + try terminal.write("A\x1b[1;32mB\x1b[0m\r\nwide: \xe7\x95\x8c"); + + const first = try terminal.compose(target, 2, 1); + try std.testing.expectEqual(ghostty.RenderState.Dirty.full, first.dirty); + try std.testing.expectEqual(@as(u32, 2), first.rows); + try std.testing.expectEqual(@as(u32, 'A'), target.get(2, 1).?.char); + try std.testing.expectEqual(@as(u32, 'B'), target.get(3, 1).?.char); + try std.testing.expect(target.get(3, 1).?.attributes & ansi.TextAttributes.BOLD != 0); + try std.testing.expect(ansi.green(target.get(3, 1).?.fg) > ansi.red(target.get(3, 1).?.fg)); + try std.testing.expect(gp.isGraphemeChar(target.get(8, 2).?.char)); + try std.testing.expect(gp.isContinuationChar(target.get(9, 2).?.char)); + + const clean = try terminal.compose(target, 2, 1); + try std.testing.expectEqual(ghostty.RenderState.Dirty.false, clean.dirty); + try std.testing.expectEqual(@as(u32, 0), clean.rows); + + terminal.invalidate(); + const invalidated = try terminal.compose(target, 1, 0); + try std.testing.expectEqual(ghostty.RenderState.Dirty.full, invalidated.dirty); + try std.testing.expectEqual(@as(u32, 2), invalidated.rows); +} + +test "embedded terminal redraws changed rows and clips composition" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + var target = try buffer.OptimizedBuffer.init(std.testing.allocator, 5, 2, .{ .pool = pool }); + defer target.deinit(); + + const terminal = try EmbeddedTerminal.init(std.testing.allocator, .{ .cols = 4, .rows = 2 }); + defer terminal.deinit(); + try terminal.write("abcd"); + _ = try terminal.compose(target, -1, 0); + try std.testing.expectEqual(@as(u32, 'b'), target.get(0, 0).?.char); + try std.testing.expectEqual(@as(u32, 'd'), target.get(2, 0).?.char); + + try terminal.write("\x1b[1;2HZ"); + const partial = try terminal.compose(target, -1, 0); + try std.testing.expectEqual(ghostty.RenderState.Dirty.partial, partial.dirty); + try std.testing.expectEqual(@as(u32, 1), partial.rows); + try std.testing.expectEqual(@as(u32, 'Z'), target.get(0, 0).?.char); + + try terminal.resize(5, 2); + const resized = try terminal.compose(target, 0, 0); + try std.testing.expectEqual(ghostty.RenderState.Dirty.full, resized.dirty); +} + +test "embedded terminal exposes cursor state" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + var target = try buffer.OptimizedBuffer.init(std.testing.allocator, 20, 4, .{ .pool = pool }); + defer target.deinit(); + + const terminal = try EmbeddedTerminal.init(std.testing.allocator, .{ .cols = 20, .rows = 4 }); + defer terminal.deinit(); + try terminal.write("\x1b[2;3H\x1b[5 q"); + _ = try terminal.compose(target, 0, 0); + + const cursor = terminal.cursor(); + try std.testing.expect(cursor.has_value); + try std.testing.expect(cursor.visible); + try std.testing.expectEqual(@as(u16, 2), cursor.x); + try std.testing.expectEqual(@as(u16, 1), cursor.y); + try std.testing.expectEqual(@as(u8, 0), cursor.style); +} + test "embedded terminal supports lifecycle, resize, and viewport scroll" { const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer terminal.deinit(); @@ -52,6 +129,7 @@ test "embedded terminal encodes long Kitty associated text" { const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 20, .rows = 4 }); defer terminal.deinit(); try terminal.write("\x1b[>19u"); + try std.testing.expectEqual(@as(u5, 19), terminal.terminal.screens.active.kitty_keyboard.current().int()); const text = "x" ** 2048; const encoded = try terminal.encodeKey(.{ .key = .unidentified, .utf8 = text }); @@ -59,6 +137,17 @@ test "embedded terminal encodes long Kitty associated text" { try std.testing.expectEqualStrings(text, encoded); } +test "embedded terminal encodes Kitty key releases" { + const terminal = try EmbeddedTerminal.init(std.testing.allocator, .{ .cols = 20, .rows = 4 }); + defer terminal.deinit(); + try terminal.write("\x1b[>3u"); + try std.testing.expectEqual(@as(u5, 3), terminal.terminal.screens.active.kitty_keyboard.current().int()); + + const encoded = try terminal.encodeKey(.{ .action = .release, .key = .key_a, .unshifted_codepoint = 'a' }); + defer terminal.freeEncoded(encoded); + try std.testing.expectEqualStrings("\x1b[97;1:3u", encoded); +} + test "embedded terminal drains generated PTY responses incrementally" { const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 20, .rows = 4 }); defer terminal.deinit(); diff --git a/packages/core/src/zig/embedded-terminal/unavailable.zig b/packages/core/src/zig/embedded-terminal/unavailable.zig new file mode 100644 index 000000000..dd185468b --- /dev/null +++ b/packages/core/src/zig/embedded-terminal/unavailable.zig @@ -0,0 +1,52 @@ +const buffer = @import("../buffer.zig"); + +pub const Error = error{Unsupported}; + +pub const ComposeResult = struct { + dirty: enum(u8) { false, partial, full }, + rows: u32 = 0, + cells: u32 = 0, +}; + +pub const Cursor = struct { + x: u16 = 0, + y: u16 = 0, + has_value: bool = false, + visible: bool = false, + blinking: bool = false, + wide_tail: bool = false, + style: u8 = 1, + color: ?struct { r: u8, g: u8, b: u8 } = null, +}; + +pub const EmbeddedTerminal = struct { + pub fn init(_: anytype, _: anytype) Error!*EmbeddedTerminal { + return error.Unsupported; + } + + pub fn deinit(_: *EmbeddedTerminal) void {} + pub fn write(_: *EmbeddedTerminal, _: []const u8) Error!void { + return error.Unsupported; + } + pub fn resize(_: *EmbeddedTerminal, _: u16, _: u16) Error!void { + return error.Unsupported; + } + pub fn scroll(_: *EmbeddedTerminal, _: i32) void {} + pub fn invalidate(_: *EmbeddedTerminal) void {} + pub fn compose(_: *EmbeddedTerminal, _: *buffer.OptimizedBuffer, _: i32, _: i32) Error!ComposeResult { + return error.Unsupported; + } + pub fn cursor(_: *EmbeddedTerminal) Cursor { + return .{}; + } + pub fn encodePaste(_: *EmbeddedTerminal, _: []const u8) Error![]u8 { + return error.Unsupported; + } + pub fn encodeFocus(_: *EmbeddedTerminal, _: bool) Error![]u8 { + return error.Unsupported; + } + pub fn freeEncoded(_: *EmbeddedTerminal, _: []u8) void {} + pub fn drainResponses(_: *EmbeddedTerminal, _: []u8) Error!usize { + return error.Unsupported; + } +}; diff --git a/packages/core/src/zig/handles.zig b/packages/core/src/zig/handles.zig index 2ed0b84af..fcb4e622f 100644 --- a/packages/core/src/zig/handles.zig +++ b/packages/core/src/zig/handles.zig @@ -25,6 +25,7 @@ pub const ObjectKind = enum(u4) { audio_engine = 8, native_renderable = 9, image = 10, + embedded_terminal = 11, }; const SlotState = enum(u8) { diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index 0b90d1c27..b2ec7e800 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -26,6 +26,11 @@ const ghostty_vt_available = @import("ghostty_vt_options").available; const ghostty_vt = if (ghostty_vt_available) @import("ghostty-vt.zig") else struct { const vt = struct {}; }; +const embedded_terminal = if (ghostty_vt_available) + @import("embedded-terminal/main.zig") +else + @import("embedded-terminal/unavailable.zig"); +const EmbeddedTerminal = embedded_terminal.EmbeddedTerminal; const native_renderable = @import("native-renderable.zig"); const buffer_effects = @import("buffer-methods.zig"); const handles = @import("handles.zig"); @@ -102,6 +107,60 @@ fn acquireImage(handle: NativeHandle) ?*native_image.Image { return handles.acquire(handle, .image, native_image.Image); } +fn acquireEmbeddedTerminal(handle: NativeHandle) ?*EmbeddedTerminal { + return handles.acquire(handle, .embedded_terminal, EmbeddedTerminal); +} + +const EmbeddedTerminalStatus = struct { + const invalid: i32 = -1; + const out_of_memory: i32 = -2; + const unsupported: i32 = -3; + const out_of_space: i32 = -4; + const processing_failed: i32 = -5; +}; + +pub const ExternalEmbeddedTerminalComposeResult = extern struct { + rows: u32 = 0, + cells: u32 = 0, + dirty: u8 = 0, + _padding: [3]u8 = .{ 0, 0, 0 }, +}; + +pub const ExternalEmbeddedTerminalCursor = extern struct { + x: u16 = 0, + y: u16 = 0, + has_value: u8 = 0, + visible: u8 = 0, + blinking: u8 = 0, + wide_tail: u8 = 0, + style: u8 = 1, + color_has_value: u8 = 0, + color_r: u8 = 0, + color_g: u8 = 0, + color_b: u8 = 0, + _padding: u8 = 0, +}; + +fn embeddedTerminalStatus(err: anyerror) i32 { + return switch (err) { + error.OutOfMemory => EmbeddedTerminalStatus.out_of_memory, + error.Unsupported => EmbeddedTerminalStatus.unsupported, + error.ResponseOverflow => EmbeddedTerminalStatus.out_of_space, + error.ProcessingFailed => EmbeddedTerminalStatus.processing_failed, + else => EmbeddedTerminalStatus.invalid, + }; +} + +fn embeddedTerminalInput(ptr: ?[*]const u8, len: u32) ?[]const u8 { + if (len == 0) return ""; + return (ptr orelse return null)[0..@as(usize, len)]; +} + +fn embeddedTerminalOutput(ptr: ?[*]u8, len: u32) ?[]u8 { + if (len == 0) return &.{}; + return (ptr orelse return null)[0..@as(usize, len)]; +} + fn emptyLineInfo(outPtr: *ExternalLineInfo) void { outPtr.* = .{ .start_cols_ptr = EMPTY_U32[0..].ptr, @@ -132,6 +191,8 @@ inline fn selectionStyle(bg: ?RGBA, fg: ?RGBA) text_buffer_view.SelectionStyle { } comptime { + std.debug.assert(@sizeOf(ExternalEmbeddedTerminalComposeResult) == 12); + std.debug.assert(@sizeOf(ExternalEmbeddedTerminalCursor) == 14); _ = native_span_feed; _ = native_audio; _ = ghostty_vt.vt; @@ -140,6 +201,219 @@ comptime { _ = native_image; } +export fn createEmbeddedTerminal(cols: u16, rows: u16, max_scrollback: u32, out_handle_ptr: ?*NativeHandle) i32 { + const out_handle = out_handle_ptr orelse return EmbeddedTerminalStatus.invalid; + out_handle.* = INVALID_HANDLE; + const terminal_value = EmbeddedTerminal.init(globalAllocator, .{ + .cols = cols, + .rows = rows, + .max_scrollback = max_scrollback, + }) catch |err| return embeddedTerminalStatus(err); + out_handle.* = handles.insert(.embedded_terminal, erasePtr(terminal_value)) catch { + terminal_value.deinit(); + return EmbeddedTerminalStatus.out_of_memory; + }; + return 0; +} + +export fn destroyEmbeddedTerminal(handle: NativeHandle) void { + const token = handles.beginDestroy(handle, .embedded_terminal, EmbeddedTerminal) orelse return; + token.ptr.deinit(); + handles.finishDestroy(token.handle); +} + +export fn embeddedTerminalWrite(handle: NativeHandle, bytes_ptr: ?[*]const u8, bytes_len: u32) i32 { + const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; + const bytes = embeddedTerminalInput(bytes_ptr, bytes_len) orelse return EmbeddedTerminalStatus.invalid; + terminal_value.write(bytes) catch |err| return embeddedTerminalStatus(err); + return 0; +} + +export fn embeddedTerminalResize(handle: NativeHandle, cols: u16, rows: u16) i32 { + const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; + terminal_value.resize(cols, rows) catch |err| return embeddedTerminalStatus(err); + return 0; +} + +export fn embeddedTerminalInvalidate(handle: NativeHandle) i32 { + const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; + terminal_value.invalidate(); + return 0; +} + +export fn embeddedTerminalScroll(handle: NativeHandle, delta: i32) i32 { + const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; + terminal_value.scroll(delta); + return 0; +} + +export fn embeddedTerminalCompose( + handle: NativeHandle, + buffer_handle: NativeHandle, + x: i32, + y: i32, + out_result_ptr: ?*ExternalEmbeddedTerminalComposeResult, +) i32 { + const out_result = out_result_ptr orelse return EmbeddedTerminalStatus.invalid; + out_result.* = .{}; + const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; + const target = acquireBuffer(buffer_handle) orelse return EmbeddedTerminalStatus.invalid; + const result = terminal_value.compose(target, x, y) catch |err| return embeddedTerminalStatus(err); + out_result.* = .{ + .rows = result.rows, + .cells = result.cells, + .dirty = @intCast(@intFromEnum(result.dirty)), + }; + return 0; +} + +export fn embeddedTerminalCursor(handle: NativeHandle, out_cursor_ptr: ?*ExternalEmbeddedTerminalCursor) i32 { + const out_cursor = out_cursor_ptr orelse return EmbeddedTerminalStatus.invalid; + out_cursor.* = .{}; + const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; + const cursor = terminal_value.cursor(); + out_cursor.* = .{ + .x = cursor.x, + .y = cursor.y, + .has_value = @intFromBool(cursor.has_value), + .visible = @intFromBool(cursor.visible), + .blinking = @intFromBool(cursor.blinking), + .wide_tail = @intFromBool(cursor.wide_tail), + .style = cursor.style, + .color_has_value = @intFromBool(cursor.color != null), + .color_r = if (cursor.color) |value| value.r else 0, + .color_g = if (cursor.color) |value| value.g else 0, + .color_b = if (cursor.color) |value| value.b else 0, + }; + return 0; +} + +export fn embeddedTerminalEncodeKey( + handle: NativeHandle, + action: u8, + key_ptr: ?[*]const u8, + key_len: u32, + mods: u16, + consumed_mods: u16, + composing: u8, + utf8_ptr: ?[*]const u8, + utf8_len: u32, + unshifted_codepoint: u32, + out_ptr: ?[*]u8, + out_len: u32, + out_required_ptr: ?*u32, +) i32 { + if (comptime !ghostty_vt_available) return EmbeddedTerminalStatus.unsupported; + const out_required = out_required_ptr orelse return EmbeddedTerminalStatus.invalid; + out_required.* = 0; + const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; + const key_code = embeddedTerminalInput(key_ptr, key_len) orelse return EmbeddedTerminalStatus.invalid; + const utf8_bytes = embeddedTerminalInput(utf8_ptr, utf8_len) orelse return EmbeddedTerminalStatus.invalid; + const output = embeddedTerminalOutput(out_ptr, out_len) orelse return EmbeddedTerminalStatus.invalid; + if (composing > 1 or mods & ~@as(u16, 0x3f) != 0 or consumed_mods & ~@as(u16, 0x3f) != 0) return EmbeddedTerminalStatus.invalid; + const key_value = ghostty_vt.vt.input.Key.fromW3C(key_code) orelse .unidentified; + if (unshifted_codepoint > std.math.maxInt(u21)) return EmbeddedTerminalStatus.invalid; + const encoded = terminal_value.encodeKey(.{ + .action = switch (action) { + 0 => .release, + 1 => .press, + 2 => .repeat, + else => return EmbeddedTerminalStatus.invalid, + }, + .key = key_value, + .mods = @bitCast(mods), + .consumed_mods = @bitCast(consumed_mods), + .composing = composing == 1, + .utf8 = utf8_bytes, + .unshifted_codepoint = @intCast(unshifted_codepoint), + }) catch |err| return embeddedTerminalStatus(err); + defer terminal_value.freeEncoded(encoded); + out_required.* = @intCast(encoded.len); + if (encoded.len > output.len) return EmbeddedTerminalStatus.out_of_space; + @memcpy(output[0..encoded.len], encoded); + return @intCast(encoded.len); +} + +export fn embeddedTerminalEncodeMouse( + handle: NativeHandle, + action: u8, + button: i8, + mods: u16, + x: f32, + y: f32, + any_button_pressed: u8, + out_ptr: ?[*]u8, + out_len: u32, +) i32 { + if (comptime !ghostty_vt_available) return EmbeddedTerminalStatus.unsupported; + const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; + const output = embeddedTerminalOutput(out_ptr, out_len) orelse return EmbeddedTerminalStatus.invalid; + if (any_button_pressed > 1 or mods & ~@as(u16, 0x3f) != 0) return EmbeddedTerminalStatus.invalid; + const encoded = terminal_value.encodeMouse(.{ + .action = switch (action) { + 0 => .press, + 1 => .release, + 2 => .motion, + else => return EmbeddedTerminalStatus.invalid, + }, + .button = switch (button) { + -1 => null, + 0 => .unknown, + 1 => .left, + 2 => .right, + 3 => .middle, + 4 => .four, + 5 => .five, + 6 => .six, + 7 => .seven, + else => return EmbeddedTerminalStatus.invalid, + }, + .mods = @bitCast(mods), + .x = x, + .y = y, + .any_button_pressed = any_button_pressed == 1, + }) catch |err| return embeddedTerminalStatus(err); + defer terminal_value.freeEncoded(encoded); + if (encoded.len > output.len) return EmbeddedTerminalStatus.out_of_space; + @memcpy(output[0..encoded.len], encoded); + return @intCast(encoded.len); +} + +export fn embeddedTerminalEncodePaste( + handle: NativeHandle, + input_ptr: ?[*]const u8, + input_len: u32, + out_ptr: ?[*]u8, + out_len: u32, +) i32 { + const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; + const input = embeddedTerminalInput(input_ptr, input_len) orelse return EmbeddedTerminalStatus.invalid; + const output = embeddedTerminalOutput(out_ptr, out_len) orelse return EmbeddedTerminalStatus.invalid; + const encoded = terminal_value.encodePaste(input) catch |err| return embeddedTerminalStatus(err); + defer terminal_value.freeEncoded(encoded); + if (encoded.len > output.len) return EmbeddedTerminalStatus.out_of_space; + @memcpy(output[0..encoded.len], encoded); + return @intCast(encoded.len); +} + +export fn embeddedTerminalEncodeFocus(handle: NativeHandle, focused: u8, out_ptr: ?[*]u8, out_len: u32) i32 { + const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; + const output = embeddedTerminalOutput(out_ptr, out_len) orelse return EmbeddedTerminalStatus.invalid; + if (focused > 1) return EmbeddedTerminalStatus.invalid; + const encoded = terminal_value.encodeFocus(focused == 1) catch |err| return embeddedTerminalStatus(err); + defer terminal_value.freeEncoded(encoded); + if (encoded.len > output.len) return EmbeddedTerminalStatus.out_of_space; + @memcpy(output[0..encoded.len], encoded); + return @intCast(encoded.len); +} + +export fn embeddedTerminalDrainResponses(handle: NativeHandle, out_ptr: ?[*]u8, out_len: u32) i32 { + const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; + const output = embeddedTerminalOutput(out_ptr, out_len) orelse return EmbeddedTerminalStatus.invalid; + const written = terminal_value.drainResponses(output) catch |err| return embeddedTerminalStatus(err); + return @intCast(written); +} + export fn setLogCallback(callback: ?*const fn (level: u8, msgPtr: [*]const u8, msgLen: u32) callconv(.c) void) void { logger.setLogCallback(callback); } diff --git a/packages/core/src/zig/tests/buffer_test.zig b/packages/core/src/zig/tests/buffer_test.zig index 0307d8f62..d5f5ca679 100644 --- a/packages/core/src/zig/tests/buffer_test.zig +++ b/packages/core/src/zig/tests/buffer_test.zig @@ -687,6 +687,20 @@ test "OptimizedBuffer - drawText with ASCII" { try std.testing.expectEqual(@as(u32, 'e'), cell_e.char); } +test "OptimizedBuffer - drawGrapheme preserves authoritative width" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init(std.testing.allocator, 4, 1, .{ .pool = pool, .id = "grapheme-width-buffer" }); + defer buf.deinit(); + const fg = ansi.rgbaFromFloats(1.0, 1.0, 1.0, 1.0); + const bg = ansi.rgbaFromFloats(0.0, 0.0, 0.0, 1.0); + + try buf.drawGrapheme("A", 2, 0, 0, fg, bg, 0); + try std.testing.expect(gp.isGraphemeChar(buf.get(0, 0).?.char)); + try std.testing.expect(gp.isContinuationChar(buf.get(1, 0).?.char)); +} + test "OptimizedBuffer - alpha blending downgrades blended metadata to rgb" { const pool = gp.initGlobalPool(std.testing.allocator); defer gp.deinitGlobalPool(); diff --git a/packages/core/tsconfig.node-test.json b/packages/core/tsconfig.node-test.json index 4ef9da8fd..bdede2a40 100644 --- a/packages/core/tsconfig.node-test.json +++ b/packages/core/tsconfig.node-test.json @@ -35,6 +35,7 @@ "src/renderables/Diff.regression.test.ts", "src/renderables/Diff.test.ts", "src/renderables/EditBufferRenderable.test.ts", + "src/renderables/EmbeddedTerminal.test.ts", "src/renderables/Input.test.ts", "src/renderables/Select.test.ts", "src/renderables/Slider.test.ts", From bac89011f4d9be082bcc70fd8eb397312ec8ff35 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 5 Aug 2026 18:15:20 +0000 Subject: [PATCH 2/6] fix(core): pack embedded terminal key options --- packages/core/src/zig-structs.ts | 9 +++++++++ packages/core/src/zig.ts | 23 +++++++++++++++++------ packages/core/src/zig/lib.zig | 31 +++++++++++++++++++------------ 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/packages/core/src/zig-structs.ts b/packages/core/src/zig-structs.ts index 536d3b113..e41ba60a7 100644 --- a/packages/core/src/zig-structs.ts +++ b/packages/core/src/zig-structs.ts @@ -216,6 +216,15 @@ export const EmbeddedTerminalCursorStruct = defineStruct([ ["padding", "u8"], ]) +export const EmbeddedTerminalKeyOptionsStruct = defineStruct([ + ["action", "u8"], + ["composing", "u8"], + ["mods", "u16"], + ["consumedMods", "u16"], + ["padding", "u16"], + ["unshiftedCodepoint", "u32"], +]) + export const CursorStyleOptionsStruct = defineStruct([ ["style", "u8", { default: 255 }], ["blinking", "u8", { default: 255 }], diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 802450fa7..170192211 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -50,6 +50,7 @@ import { CursorStateStruct, EmbeddedTerminalComposeResultStruct, EmbeddedTerminalCursorStruct, + EmbeddedTerminalKeyOptionsStruct, CursorStyleOptionsStruct, GridDrawOptionsStruct, NativeSpanFeedOptionsStruct, @@ -387,7 +388,7 @@ function getOpenTUILib(libPath?: string) { returns: "i32", }, embeddedTerminalEncodeKey: { - args: ["u32", "u8", "ptr", "u32", "u16", "u16", "u8", "ptr", "u32", "u32", "ptr", "u32", "ptr"], + args: ["u32", "ptr", "ptr", "u32", "ptr", "u32", "ptr", "u32", "ptr"], returns: "i32", }, embeddedTerminalEncodeMouse: { @@ -2944,6 +2945,7 @@ class FFIRenderLib implements RenderLib { }, embeddedTerminalCompose: allocStruct(EmbeddedTerminalComposeResultStruct), embeddedTerminalCursor: allocStruct(EmbeddedTerminalCursorStruct), + embeddedTerminalKeyOptions: allocStruct(EmbeddedTerminalKeyOptionsStruct), audioStreamStats: { ...allocStruct(AudioStreamStatsStruct), result: { @@ -3087,6 +3089,19 @@ class FFIRenderLib implements RenderLib { } public embeddedTerminalEncodeKey(handle: EmbeddedTerminalHandle, key: EmbeddedTerminalKey): Uint8Array { + const options = this.ffiStructStorage.embeddedTerminalKeyOptions + EmbeddedTerminalKeyOptionsStruct.packInto( + { + action: { release: 0, press: 1, repeat: 2 }[key.action ?? "press"], + composing: key.composing ? 1 : 0, + mods: key.mods ?? 0, + consumedMods: key.consumedMods ?? 0, + padding: 0, + unshiftedCodepoint: key.unshiftedCodepoint ?? 0, + }, + options.view, + 0, + ) const keyCode = key.key ? this.encoder.encode(key.key) : new Uint8Array() const keyCodeLength = toSafeFFIU32Length(keyCode.byteLength, "Embedded terminal physical key length") const text = key.text ? this.encoder.encode(key.text) : new Uint8Array() @@ -3095,15 +3110,11 @@ class FFIRenderLib implements RenderLib { const encode = (output: Uint8Array) => this.opentui.symbols.embeddedTerminalEncodeKey( handle, - { release: 0, press: 1, repeat: 2 }[key.action ?? "press"], + options.buffer, keyCodeLength === 0 ? null : keyCode, keyCodeLength, - key.mods ?? 0, - key.consumedMods ?? 0, - key.composing ? 1 : 0, textLength === 0 ? null : text, textLength, - key.unshiftedCodepoint ?? 0, output, output.byteLength, required, diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index b2ec7e800..e25b87915 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -141,6 +141,15 @@ pub const ExternalEmbeddedTerminalCursor = extern struct { _padding: u8 = 0, }; +pub const ExternalEmbeddedTerminalKeyOptions = extern struct { + action: u8 = 1, + composing: u8 = 0, + mods: u16 = 0, + consumed_mods: u16 = 0, + _padding: u16 = 0, + unshifted_codepoint: u32 = 0, +}; + fn embeddedTerminalStatus(err: anyerror) i32 { return switch (err) { error.OutOfMemory => EmbeddedTerminalStatus.out_of_memory, @@ -193,6 +202,7 @@ inline fn selectionStyle(bg: ?RGBA, fg: ?RGBA) text_buffer_view.SelectionStyle { comptime { std.debug.assert(@sizeOf(ExternalEmbeddedTerminalComposeResult) == 12); std.debug.assert(@sizeOf(ExternalEmbeddedTerminalCursor) == 14); + std.debug.assert(@sizeOf(ExternalEmbeddedTerminalKeyOptions) == 12); _ = native_span_feed; _ = native_audio; _ = ghostty_vt.vt; @@ -290,42 +300,39 @@ export fn embeddedTerminalCursor(handle: NativeHandle, out_cursor_ptr: ?*Externa export fn embeddedTerminalEncodeKey( handle: NativeHandle, - action: u8, + options_ptr: ?*const ExternalEmbeddedTerminalKeyOptions, key_ptr: ?[*]const u8, key_len: u32, - mods: u16, - consumed_mods: u16, - composing: u8, utf8_ptr: ?[*]const u8, utf8_len: u32, - unshifted_codepoint: u32, out_ptr: ?[*]u8, out_len: u32, out_required_ptr: ?*u32, ) i32 { if (comptime !ghostty_vt_available) return EmbeddedTerminalStatus.unsupported; + const options = options_ptr orelse return EmbeddedTerminalStatus.invalid; const out_required = out_required_ptr orelse return EmbeddedTerminalStatus.invalid; out_required.* = 0; const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; const key_code = embeddedTerminalInput(key_ptr, key_len) orelse return EmbeddedTerminalStatus.invalid; const utf8_bytes = embeddedTerminalInput(utf8_ptr, utf8_len) orelse return EmbeddedTerminalStatus.invalid; const output = embeddedTerminalOutput(out_ptr, out_len) orelse return EmbeddedTerminalStatus.invalid; - if (composing > 1 or mods & ~@as(u16, 0x3f) != 0 or consumed_mods & ~@as(u16, 0x3f) != 0) return EmbeddedTerminalStatus.invalid; + if (options.composing > 1 or options.mods & ~@as(u16, 0x3f) != 0 or options.consumed_mods & ~@as(u16, 0x3f) != 0) return EmbeddedTerminalStatus.invalid; const key_value = ghostty_vt.vt.input.Key.fromW3C(key_code) orelse .unidentified; - if (unshifted_codepoint > std.math.maxInt(u21)) return EmbeddedTerminalStatus.invalid; + if (options.unshifted_codepoint > std.math.maxInt(u21)) return EmbeddedTerminalStatus.invalid; const encoded = terminal_value.encodeKey(.{ - .action = switch (action) { + .action = switch (options.action) { 0 => .release, 1 => .press, 2 => .repeat, else => return EmbeddedTerminalStatus.invalid, }, .key = key_value, - .mods = @bitCast(mods), - .consumed_mods = @bitCast(consumed_mods), - .composing = composing == 1, + .mods = @bitCast(options.mods), + .consumed_mods = @bitCast(options.consumed_mods), + .composing = options.composing == 1, .utf8 = utf8_bytes, - .unshifted_codepoint = @intCast(unshifted_codepoint), + .unshifted_codepoint = @intCast(options.unshifted_codepoint), }) catch |err| return embeddedTerminalStatus(err); defer terminal_value.freeEncoded(encoded); out_required.* = @intCast(encoded.len); From d779659b53fd357bc636dd1b0f7e46e841a69732 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 5 Aug 2026 20:16:35 +0000 Subject: [PATCH 3/6] fix(core): harden embedded terminal failure paths --- .../src/renderables/EmbeddedTerminal.test.ts | 28 +++++++++++++++++++ .../core/src/renderables/EmbeddedTerminal.ts | 2 +- packages/core/src/zig.ts | 8 +++--- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/core/src/renderables/EmbeddedTerminal.test.ts b/packages/core/src/renderables/EmbeddedTerminal.test.ts index 0d5ab0294..46c20a393 100644 --- a/packages/core/src/renderables/EmbeddedTerminal.test.ts +++ b/packages/core/src/renderables/EmbeddedTerminal.test.ts @@ -4,6 +4,12 @@ import { KeyEvent } from "../lib/KeyHandler.js" import { resolveRenderLib } from "../zig.js" import { EmbeddedTerminalRenderable } from "./EmbeddedTerminal.js" +class MissingFramebufferTerminal extends EmbeddedTerminalRenderable { + protected createFrameBuffer(): void { + this.frameBuffer = null + } +} + describe("EmbeddedTerminalRenderable", () => { let setup: TestRendererSetup @@ -20,6 +26,14 @@ describe("EmbeddedTerminalRenderable", () => { lib.destroyEmbeddedTerminal(handle) }) + test("does not compose into the parent buffer when framebuffer allocation fails", async () => { + const terminal = new MissingFramebufferTerminal(setup.renderer, { width: 20, height: 4 }) + setup.renderer.root.add(terminal) + terminal.write("must not reach the parent") + await setup.renderOnce() + expect(setup.captureCharFrame()).not.toContain("must not reach the parent") + }) + test("renders VT output and preserves it across clean frames", async () => { const terminal = new EmbeddedTerminalRenderable(setup.renderer, { width: 20, height: 4 }) setup.renderer.root.add(terminal) @@ -81,6 +95,20 @@ describe("EmbeddedTerminalRenderable", () => { } }) + test("drains the preserved response prefix after overflow", () => { + const lib = resolveRenderLib() + const handle = lib.createEmbeddedTerminal({ cols: 20, rows: 4 }) + try { + const query = "\x1b[5n" + lib.embeddedTerminalWrite(handle, query.repeat((1024 * 1024) / query.length + 1)) + const responses = lib.embeddedTerminalDrainResponses(handle) + expect(responses.byteLength).toBe(1024 * 1024) + expect(new TextDecoder().decode(responses.subarray(0, 4))).toBe("\x1b[0n") + } finally { + lib.destroyEmbeddedTerminal(handle) + } + }) + test("resizes and destroys native state idempotently", async () => { const sizes: Array<[number, number]> = [] const terminal = new EmbeddedTerminalRenderable(setup.renderer, { diff --git a/packages/core/src/renderables/EmbeddedTerminal.ts b/packages/core/src/renderables/EmbeddedTerminal.ts index b33f6259b..8f3312052 100644 --- a/packages/core/src/renderables/EmbeddedTerminal.ts +++ b/packages/core/src/renderables/EmbeddedTerminal.ts @@ -150,7 +150,7 @@ export class EmbeddedTerminalRenderable extends Renderable { } protected renderSelf(buffer: OptimizedBuffer): void { - if (!this.handle || !this.visible || this.isDestroyed) return + if (!this.handle || !this.frameBuffer || !this.visible || this.isDestroyed) return this.lib.embeddedTerminalCompose(this.handle, buffer.ptr, 0, 0) if (!this.focused) return const cursor = this.lib.embeddedTerminalCursor(this.handle) diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 170192211..4eadb4fcd 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -3179,10 +3179,10 @@ class FFIRenderLib implements RenderLib { const chunks: Uint8Array[] = [] while (true) { const output = new Uint8Array(64 * 1024) - const length = embeddedTerminalResult( - this.opentui.symbols.embeddedTerminalDrainResponses(handle, output, output.byteLength), - "response drain", - ) + const status = this.opentui.symbols.embeddedTerminalDrainResponses(handle, output, output.byteLength) + // Native preserves the bounded prefix and reports dropped excess once. + if (status === -4) continue + const length = embeddedTerminalResult(status, "response drain") if (length > 0) chunks.push(output.slice(0, length)) if (length < output.byteLength) break } From 65fdb2658fc4ecf814f719cd168ee263337ed311 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 5 Aug 2026 20:37:02 +0000 Subject: [PATCH 4/6] fix(core): encode terminal Meta as Super --- .../src/renderables/EmbeddedTerminal.test.ts | 17 +++++++++++++++++ .../core/src/renderables/EmbeddedTerminal.ts | 4 ++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/core/src/renderables/EmbeddedTerminal.test.ts b/packages/core/src/renderables/EmbeddedTerminal.test.ts index 46c20a393..c0efa3348 100644 --- a/packages/core/src/renderables/EmbeddedTerminal.test.ts +++ b/packages/core/src/renderables/EmbeddedTerminal.test.ts @@ -95,6 +95,23 @@ describe("EmbeddedTerminalRenderable", () => { } }) + test("encodes Meta as Super rather than Alt", () => { + const terminal = new EmbeddedTerminalRenderable(setup.renderer, { width: 20, height: 4 }) + setup.renderer.root.add(terminal) + terminal.write("\x1b[>3u") + + const encoded = terminal.encodeKey( + keyEvent({ + name: "a", + sequence: "a", + code: "KeyA", + baseCode: "a".codePointAt(0), + meta: true, + }), + ) + expect(new TextDecoder().decode(encoded)).toBe("\x1b[97;9u") + }) + test("drains the preserved response prefix after overflow", () => { const lib = resolveRenderLib() const handle = lib.createEmbeddedTerminal({ cols: 20, rows: 4 }) diff --git a/packages/core/src/renderables/EmbeddedTerminal.ts b/packages/core/src/renderables/EmbeddedTerminal.ts index 8f3312052..2e594f6e6 100644 --- a/packages/core/src/renderables/EmbeddedTerminal.ts +++ b/packages/core/src/renderables/EmbeddedTerminal.ts @@ -261,8 +261,8 @@ function modifiers(input: { let value = 0 if (input.shift) value |= MOD_SHIFT if (input.ctrl) value |= MOD_CTRL - if (input.alt || input.meta || input.option) value |= MOD_ALT - if (input.super) value |= MOD_SUPER + if (input.alt || input.option) value |= MOD_ALT + if (input.meta || input.super) value |= MOD_SUPER if (input.capsLock) value |= MOD_CAPS_LOCK if (input.numLock) value |= MOD_NUM_LOCK return value From c44ad8bdae86ed31571b8549b19d4b9cc06a4785 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 7 Aug 2026 18:36:32 +0000 Subject: [PATCH 5/6] fix(core): wire embedded terminal IO --- packages/core/src/zig/embedded-terminal/tests.zig | 8 ++++---- packages/core/src/zig/lib.zig | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/zig/embedded-terminal/tests.zig b/packages/core/src/zig/embedded-terminal/tests.zig index 0292f9aa2..358f0078e 100644 --- a/packages/core/src/zig/embedded-terminal/tests.zig +++ b/packages/core/src/zig/embedded-terminal/tests.zig @@ -12,7 +12,7 @@ test "embedded terminal composes dirty rows into an OptimizedBuffer" { defer target.deinit(); target.clear(ansi.rgbColor(0, 0, 0, 255), null); - const terminal = try EmbeddedTerminal.init(std.testing.allocator, .{ .cols = 8, .rows = 2 }); + const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 8, .rows = 2 }); defer terminal.deinit(); try terminal.write("A\x1b[1;32mB\x1b[0m\r\nwide: \xe7\x95\x8c"); @@ -42,7 +42,7 @@ test "embedded terminal redraws changed rows and clips composition" { var target = try buffer.OptimizedBuffer.init(std.testing.allocator, 5, 2, .{ .pool = pool }); defer target.deinit(); - const terminal = try EmbeddedTerminal.init(std.testing.allocator, .{ .cols = 4, .rows = 2 }); + const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 4, .rows = 2 }); defer terminal.deinit(); try terminal.write("abcd"); _ = try terminal.compose(target, -1, 0); @@ -66,7 +66,7 @@ test "embedded terminal exposes cursor state" { var target = try buffer.OptimizedBuffer.init(std.testing.allocator, 20, 4, .{ .pool = pool }); defer target.deinit(); - const terminal = try EmbeddedTerminal.init(std.testing.allocator, .{ .cols = 20, .rows = 4 }); + const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 20, .rows = 4 }); defer terminal.deinit(); try terminal.write("\x1b[2;3H\x1b[5 q"); _ = try terminal.compose(target, 0, 0); @@ -138,7 +138,7 @@ test "embedded terminal encodes long Kitty associated text" { } test "embedded terminal encodes Kitty key releases" { - const terminal = try EmbeddedTerminal.init(std.testing.allocator, .{ .cols = 20, .rows = 4 }); + const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 20, .rows = 4 }); defer terminal.deinit(); try terminal.write("\x1b[>3u"); try std.testing.expectEqual(@as(u5, 3), terminal.terminal.screens.active.kitty_keyboard.current().int()); diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index e25b87915..fd07870fb 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -214,7 +214,7 @@ comptime { export fn createEmbeddedTerminal(cols: u16, rows: u16, max_scrollback: u32, out_handle_ptr: ?*NativeHandle) i32 { const out_handle = out_handle_ptr orelse return EmbeddedTerminalStatus.invalid; out_handle.* = INVALID_HANDLE; - const terminal_value = EmbeddedTerminal.init(globalAllocator, .{ + const terminal_value = EmbeddedTerminal.init(io, globalAllocator, .{ .cols = cols, .rows = rows, .max_scrollback = max_scrollback, From 73fc2dd62643d1fd83ccdff5dd891dfc491cb5ee Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 7 Aug 2026 17:38:13 -0400 Subject: [PATCH 6/6] fix(core): align unavailable terminal init (#1350) ## Summary - align the no-Ghostty embedded terminal fallback constructor with the available implementation - restore custom-target builds after the IO argument was added ## Verification - `zig build -Dtarget=x86_64-freebsd -Doptimize=ReleaseFast` This is an isolated fix on top of #1340 because `unavailable.zig` is introduced by that PR and is not present on `main`. --- packages/core/src/zig/embedded-terminal/unavailable.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/zig/embedded-terminal/unavailable.zig b/packages/core/src/zig/embedded-terminal/unavailable.zig index dd185468b..1735ce15d 100644 --- a/packages/core/src/zig/embedded-terminal/unavailable.zig +++ b/packages/core/src/zig/embedded-terminal/unavailable.zig @@ -20,7 +20,7 @@ pub const Cursor = struct { }; pub const EmbeddedTerminal = struct { - pub fn init(_: anytype, _: anytype) Error!*EmbeddedTerminal { + pub fn init(_: anytype, _: anytype, _: anytype) Error!*EmbeddedTerminal { return error.Unsupported; }