diff --git a/.github/workflows/build-core.yml b/.github/workflows/build-core.yml index 6464254a2..3c1173e05 100644 --- a/.github/workflows/build-core.yml +++ b/.github/workflows/build-core.yml @@ -8,7 +8,7 @@ on: jobs: build: name: Core - Build and Test - runs-on: ubuntu-latest + runs-on: macos-latest steps: - name: Checkout code uses: actions/checkout@v4 @@ -21,7 +21,7 @@ jobs: - name: Setup Zig uses: goto-bus-stop/setup-zig@v2 with: - version: 0.14.1 + version: 0.15.2 - name: Install dependencies run: bun install @@ -34,4 +34,4 @@ jobs: - name: Run tests run: | cd packages/core - bun run test + bun run test:js diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index 0c3327524..40ae24475 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -14,7 +14,7 @@ on: default: false env: - ZIG_VERSION: 0.14.1 + ZIG_VERSION: 0.15.2 jobs: build-native: @@ -39,7 +39,10 @@ jobs: run: bun install - name: Build packages (cross-compile for all platforms) - run: bun run build + run: | + cd packages/core + bun run build:native --all + bun run build:lib - name: Verify build outputs run: | diff --git a/.github/workflows/build-react.yml b/.github/workflows/build-react.yml index 68fdd4cfd..a83e6b5d9 100644 --- a/.github/workflows/build-react.yml +++ b/.github/workflows/build-react.yml @@ -8,7 +8,7 @@ on: jobs: build: name: React - Build and Test - runs-on: ubuntu-latest + runs-on: macos-latest steps: - name: Checkout code uses: actions/checkout@v4 @@ -21,7 +21,7 @@ jobs: - name: Setup Zig uses: goto-bus-stop/setup-zig@v2 with: - version: 0.14.1 + version: 0.15.2 - name: Install dependencies run: bun install diff --git a/.github/workflows/build-solid.yml b/.github/workflows/build-solid.yml index a606f2264..2e3be1dbf 100644 --- a/.github/workflows/build-solid.yml +++ b/.github/workflows/build-solid.yml @@ -8,7 +8,7 @@ on: jobs: build: name: Solid - Build and Test - runs-on: ubuntu-latest + runs-on: macos-latest steps: - name: Checkout code uses: actions/checkout@v4 @@ -21,7 +21,7 @@ jobs: - name: Setup Zig uses: goto-bus-stop/setup-zig@v2 with: - version: 0.14.1 + version: 0.15.2 - name: Install dependencies run: bun install diff --git a/.zig-version b/.zig-version index 930e3000b..4312e0d0c 100644 --- a/.zig-version +++ b/.zig-version @@ -1 +1 @@ -0.14.1 +0.15.2 diff --git a/packages/core/package.json b/packages/core/package.json index 04cb99114..38f333030 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -29,6 +29,7 @@ "@types/bun": "latest", "@types/node": "^24.0.0", "@types/three": "0.177.0", + "bun-pty": "^0.4.2", "commander": "^13.1.0", "typescript": "^5", "web-tree-sitter": "0.25.10" diff --git a/packages/core/scripts/build.ts b/packages/core/scripts/build.ts index 624cd5099..45c8b98da 100644 --- a/packages/core/scripts/build.ts +++ b/packages/core/scripts/build.ts @@ -40,6 +40,7 @@ const args = process.argv.slice(2) const buildLib = args.find((arg) => arg === "--lib") const buildNative = args.find((arg) => arg === "--native") const isDev = args.includes("--dev") +const buildAll = args.includes("--all") // Build for all platforms (requires macOS or cross-compilation setup) const variants: Variant[] = [ { platform: "darwin", arch: "x64" }, @@ -78,16 +79,17 @@ if (missingRequired.length > 0) { } if (buildNative) { - console.log(`Building native ${isDev ? "dev" : "prod"} binaries...`) + console.log(`Building native ${isDev ? "dev" : "prod"} binaries${buildAll ? " for all platforms" : ""}...`) - const zigBuild: SpawnSyncReturns = spawnSync( - "zig", - ["build", `-Doptimize=${isDev ? "Debug" : "ReleaseFast"}`], - { - cwd: join(rootDir, "src", "zig"), - stdio: "inherit", - }, - ) + const zigArgs = ["build", `-Doptimize=${isDev ? "Debug" : "ReleaseFast"}`] + if (buildAll) { + zigArgs.push("-Dall") + } + + const zigBuild: SpawnSyncReturns = spawnSync("zig", zigArgs, { + cwd: join(rootDir, "src", "zig"), + stdio: "inherit", + }) if (zigBuild.error) { console.error("Error: Zig is not installed or not in PATH") @@ -124,16 +126,10 @@ if (buildNative) { } if (copiedFiles === 0) { - console.error(`Error: No dynamic libraries found for ${platform}-${arch} in ${libDir}`) - console.error(`Expected to find files like: libopentui.so, libopentui.dylib, opentui.dll`) - console.error(`Found files in ${libDir}:`) - if (existsSync(libDir)) { - const files = spawnSync("ls", ["-la", libDir], { stdio: "pipe" }) - if (files.stdout) console.error(files.stdout.toString()) - } else { - console.error("Directory does not exist") - } - process.exit(1) + // Skip platforms that weren't built (e.g., macOS when cross-compiling from Linux) + console.log(`Skipping ${platform}-${arch}: no libraries found (cross-compilation may not be supported)`) + rmSync(nativeDir, { recursive: true, force: true }) + continue } const indexTsContent = `const module = await import("./${libraryFileName}", { with: { type: "file" } }) diff --git a/packages/core/src/examples/terminal-interactive-demo.ts b/packages/core/src/examples/terminal-interactive-demo.ts new file mode 100644 index 000000000..a8b678c1e --- /dev/null +++ b/packages/core/src/examples/terminal-interactive-demo.ts @@ -0,0 +1,259 @@ +import { createCliRenderer, TerminalRenderable, BoxRenderable, type CliRenderer, type KeyEvent } from "../index" +import { TextRenderable } from "../renderables/Text" +import { setupCommonDemoKeys } from "./lib/standalone-keys" + +interface Button { + label: string + data: string +} + +const BUTTONS: Button[] = [ + { label: "[1] Send 'hello'", data: "hello" }, + { label: "[2] Send Enter", data: "\r" }, + { label: "[3] Send 'help'", data: "help" }, + { label: "[4] Send Escape", data: "\x1b" }, + { label: "[5] Send Ctrl+C", data: "\x03" }, + { label: "[6] Send '/clear'", data: "/clear" }, +] + +const LEFT_PANEL_WIDTH = 33 +const RIGHT_PANEL_BORDER = 2 +const VERTICAL_OVERHEAD = 3 + +let renderer: CliRenderer | null = null +let pty: any = null +let terminalDisplay: TerminalRenderable | null = null +let statusDisplay: TextRenderable | null = null +let selectedButton = 0 +let status = "Starting..." +let terminalCols = 80 +let terminalRows = 24 + +async function initPty(cols: number, rows: number): Promise { + try { + const { spawn } = await import("bun-pty") + return spawn("opencode", [], { + name: "xterm-256color", + cols, + rows, + cwd: process.cwd(), + }) + } catch (e) { + console.error("Failed to import bun-pty. Make sure it's installed: bun add bun-pty") + throw e + } +} + +function sendData(data: string): void { + if (pty) { + pty.write(data) + if (data === "\r") { + status = "Sent: Enter" + } else if (data === "\x1b") { + status = "Sent: Escape" + } else if (data === "\x03") { + status = "Sent: Ctrl+C" + } else { + status = `Sent: "${data}"` + } + updateStatus() + } +} + +function updateStatus(): void { + if (statusDisplay) { + statusDisplay.content = `Status: ${status} | Size: ${terminalCols}x${terminalRows}` + } +} + +function handleKey(key: KeyEvent): void { + if (key.name === "q" || key.name === "escape") { + if (pty) { + pty.kill() + } + process.exit(0) + } + + if (key.name === "1") sendData(BUTTONS[0].data) + if (key.name === "2") sendData(BUTTONS[1].data) + if (key.name === "3") sendData(BUTTONS[2].data) + if (key.name === "4") sendData(BUTTONS[3].data) + if (key.name === "5") sendData(BUTTONS[4].data) + if (key.name === "6") sendData(BUTTONS[5].data) + + if (key.name === "up") { + selectedButton = selectedButton > 0 ? selectedButton - 1 : BUTTONS.length - 1 + renderer?.requestRender() + } + if (key.name === "down") { + selectedButton = selectedButton < BUTTONS.length - 1 ? selectedButton + 1 : 0 + renderer?.requestRender() + } + if (key.name === "return") { + sendData(BUTTONS[selectedButton].data) + } +} + +export async function run(rendererInstance: CliRenderer): Promise { + renderer = rendererInstance + renderer.setBackgroundColor("#0d1117") + + const width = renderer.width + const height = renderer.height + terminalCols = Math.max(40, width - LEFT_PANEL_WIDTH - RIGHT_PANEL_BORDER) + terminalRows = Math.max(10, height - VERTICAL_OVERHEAD) + + const mainContainer = new BoxRenderable(renderer, { + id: "main-container", + flexDirection: "row", + flexGrow: 1, + }) + renderer.root.add(mainContainer) + + const leftPanel = new BoxRenderable(renderer, { + id: "left-panel", + width: 30, + flexDirection: "column", + padding: 1, + }) + mainContainer.add(leftPanel) + + const commandsTitle = new TextRenderable(renderer, { + id: "commands-title", + content: "Commands", + fg: "#58a6ff", + marginBottom: 1, + }) + leftPanel.add(commandsTitle) + + for (let i = 0; i < BUTTONS.length; i++) { + const btn = BUTTONS[i] + const isSelected = i === selectedButton + const buttonText = new TextRenderable(renderer, { + id: `button-${i}`, + content: btn.label, + fg: isSelected ? "#000" : "#d4d4d4", + bg: isSelected ? "#58a6ff" : undefined, + }) + leftPanel.add(buttonText) + } + + const helpText = new TextRenderable(renderer, { + id: "help-text", + content: "Use arrow keys + Enter\nor number keys 1-6\n\nPress 'q' to quit", + fg: "#8b949e", + marginTop: 2, + }) + leftPanel.add(helpText) + + statusDisplay = new TextRenderable(renderer, { + id: "status-text", + content: `Status: ${status}`, + fg: "#8b949e", + marginTop: 2, + }) + leftPanel.add(statusDisplay) + + const rightPanel = new BoxRenderable(renderer, { + id: "right-panel", + flexGrow: 1, + flexDirection: "column", + marginLeft: 1, + }) + mainContainer.add(rightPanel) + + const terminalTitle = new TextRenderable(renderer, { + id: "terminal-title", + content: "Terminal Output", + fg: "#58a6ff", + height: 1, + paddingLeft: 1, + bg: "#333", + }) + rightPanel.add(terminalTitle) + + try { + pty = await initPty(terminalCols, terminalRows) + + // Create streams from PTY + const readable = new ReadableStream({ + start(controller) { + pty.onData((data: string) => controller.enqueue(data)) + pty.onExit(() => controller.close()) + }, + }) + + const writable = new WritableStream({ + write(chunk) { + pty.write(chunk) + }, + }) + + terminalDisplay = new TerminalRenderable(renderer, { + id: "terminal-display", + cols: terminalCols, + rows: terminalRows, + trimEnd: true, + flexGrow: 1, + readable, + writable, + }) + rightPanel.add(terminalDisplay) + + pty.onExit(({ exitCode }: { exitCode: number }) => { + status = `Process exited with code ${exitCode}` + updateStatus() + }) + + status = "Running opencode" + updateStatus() + } catch (e) { + status = "Failed to start PTY" + updateStatus() + } + + renderer.on("resize", (newWidth: number, newHeight: number) => { + terminalCols = Math.max(40, newWidth - LEFT_PANEL_WIDTH - RIGHT_PANEL_BORDER) + terminalRows = Math.max(10, newHeight - VERTICAL_OVERHEAD) + pty?.resize(terminalCols, terminalRows) + if (terminalDisplay) { + terminalDisplay.cols = terminalCols + terminalDisplay.rows = terminalRows + } + updateStatus() + }) + + rendererInstance.keyInput.on("keypress", handleKey) +} + +export function destroy(rendererInstance: CliRenderer): void { + rendererInstance.keyInput.off("keypress", handleKey) + + if (pty) { + pty.kill() + pty = null + } + + if (terminalDisplay) { + terminalDisplay.destroy() + terminalDisplay = null + } + + if (statusDisplay) { + statusDisplay.destroy() + statusDisplay = null + } + + rendererInstance.root.remove("main-container") + renderer = null +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ + exitOnCtrlC: false, + }) + + await run(renderer) + setupCommonDemoKeys(renderer) + renderer.start() +} diff --git a/packages/core/src/examples/terminal-simple-demo.ts b/packages/core/src/examples/terminal-simple-demo.ts new file mode 100644 index 000000000..e00de6ebe --- /dev/null +++ b/packages/core/src/examples/terminal-simple-demo.ts @@ -0,0 +1,162 @@ +import { + createCliRenderer, + StatelessTerminalRenderable, + BoxRenderable, + type CliRenderer, + type KeyEvent, + ScrollBoxRenderable, +} from "../index" +import { TextRenderable } from "../renderables/Text" +import { setupCommonDemoKeys } from "./lib/standalone-keys" + +let renderer: CliRenderer | null = null +let terminalDisplay: StatelessTerminalRenderable | null = null +let scrollBox: ScrollBoxRenderable | null = null +let statusDisplay: TextRenderable | null = null + +const SAMPLE_ANSI = `\x1b[1;32muser@hostname\x1b[0m:\x1b[1;34m~/projects/my-app\x1b[0m$ ls -la +total 128 +drwxr-xr-x 12 user user 4096 Nov 26 10:30 \x1b[1;34m.\x1b[0m +drwxr-xr-x 5 user user 4096 Nov 25 14:22 \x1b[1;34m..\x1b[0m +-rw-r--r-- 1 user user 234 Nov 26 10:30 .gitignore +drwxr-xr-x 8 user user 4096 Nov 26 10:28 \x1b[1;34m.git\x1b[0m +-rw-r--r-- 1 user user 1842 Nov 26 09:15 package.json + +\x1b[1;32muser@hostname\x1b[0m:\x1b[1;34m~/projects/my-app\x1b[0m$ git status +On branch \x1b[1;36mmain\x1b[0m +Changes to be committed: + \x1b[32mmodified: src/index.ts\x1b[0m + \x1b[32mnew file: src/utils.ts\x1b[0m + +Changes not staged for commit: + \x1b[31mmodified: package.json\x1b[0m + +\x1b[1;32muser@hostname\x1b[0m:\x1b[1;34m~/projects/my-app\x1b[0m$ npm run build +\x1b[1;33m[WARN]\x1b[0m Deprecation warning: 'fs.exists' is deprecated +\x1b[1;36m[INFO]\x1b[0m Compiling TypeScript files... +\x1b[1;32m[SUCCESS]\x1b[0m Build completed in 2.34s + +\x1b[1;32muser@hostname\x1b[0m:\x1b[1;34m~/projects/my-app\x1b[0m$ echo "Style showcase:" +Style showcase: + +\x1b[1mBold text\x1b[0m +\x1b[2mFaint/dim text\x1b[0m +\x1b[3mItalic text\x1b[0m +\x1b[4mUnderlined text\x1b[0m +\x1b[7mInverse/reverse text\x1b[0m +\x1b[9mStrikethrough text\x1b[0m + +\x1b[31mRed\x1b[0m \x1b[32mGreen\x1b[0m \x1b[33mYellow\x1b[0m \x1b[34mBlue\x1b[0m \x1b[35mMagenta\x1b[0m \x1b[36mCyan\x1b[0m +\x1b[38;5;208mOrange (256 color)\x1b[0m +\x1b[38;2;255;105;180mHot Pink (RGB)\x1b[0m +` + +let currentAnsi = SAMPLE_ANSI +let prefixCount = 0 + +export function run(rendererInstance: CliRenderer): void { + renderer = rendererInstance + renderer.setBackgroundColor("#0d1117") + + const container = new BoxRenderable(renderer, { + id: "container", + flexDirection: "column", + flexGrow: 1, + }) + renderer.root.add(container) + + statusDisplay = new TextRenderable(renderer, { + id: "status", + content: "Press 'p' to add prefix | 't' scroll top | 'b' scroll bottom | 'q' to quit", + height: 1, + fg: "#8b949e", + padding: 1, + }) + container.add(statusDisplay) + + scrollBox = new ScrollBoxRenderable(renderer, { + id: "scroll-box", + flexGrow: 1, + padding: 1, + }) + container.add(scrollBox) + + terminalDisplay = new StatelessTerminalRenderable(renderer, { + id: "terminal", + ansi: currentAnsi, + cols: 120, + rows: 100, + trimEnd: true, + }) + scrollBox.add(terminalDisplay) + + rendererInstance.keyInput.on("keypress", handleKey) +} + +function handleKey(key: KeyEvent): void { + if (key.name === "q" || key.name === "escape") { + process.exit(0) + } + + if (key.name === "p" && terminalDisplay) { + prefixCount++ + const prefix = `\x1b[1;35m[PREFIX ${prefixCount}]\x1b[0m\n` + currentAnsi = prefix + currentAnsi + terminalDisplay.ansi = currentAnsi + updateStatus() + } + + if (key.name === "t" && scrollBox) { + scrollBox.scrollTo(0) + } + + if (key.name === "b" && scrollBox && terminalDisplay) { + const lastLine = terminalDisplay.lineCount - 1 + const scrollPos = terminalDisplay.getScrollPositionForLine(lastLine) + scrollBox.scrollTo(scrollPos) + } +} + +function updateStatus(): void { + if (statusDisplay && terminalDisplay) { + statusDisplay.content = `Press 'p' to add prefix | 't' top | 'b' bottom | 'q' quit | Prefixes: ${prefixCount} | Lines: ${terminalDisplay.lineCount}` + } +} + +export function destroy(rendererInstance: CliRenderer): void { + rendererInstance.keyInput.off("keypress", handleKey) + + if (terminalDisplay) { + terminalDisplay.destroy() + terminalDisplay = null + } + + if (scrollBox) { + scrollBox.destroy() + scrollBox = null + } + + if (statusDisplay) { + statusDisplay.destroy() + statusDisplay = null + } + + rendererInstance.root.remove("container") + renderer = null +} + +if (import.meta.main) { + const inputFile = process.argv[2] + if (inputFile) { + const fs = await import("fs") + currentAnsi = fs.readFileSync(inputFile, "utf-8") + } + + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + }) + + run(renderer) + setupCommonDemoKeys(renderer) + renderer.start() +} diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 71496bbcb..80527ba0b 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -16,3 +16,4 @@ export * from "./tree-sitter" export * from "./data-paths" export * from "./extmarks" export * from "./terminal-palette" +export * from "./vterm-ffi" diff --git a/packages/core/src/lib/vterm-ffi.ts b/packages/core/src/lib/vterm-ffi.ts new file mode 100644 index 000000000..d5ce1341f --- /dev/null +++ b/packages/core/src/lib/vterm-ffi.ts @@ -0,0 +1,80 @@ +import { StyledText } from "./styled-text" +import { RGBA } from "./RGBA" +import type { TextChunk } from "../text-buffer" +import { TextAttributes } from "../types" + +const DEFAULT_FG = RGBA.fromHex("#d4d4d4") + +export const VTermStyleFlags = { + BOLD: 1, + ITALIC: 2, + UNDERLINE: 4, + STRIKETHROUGH: 8, + INVERSE: 16, + FAINT: 32, +} as const + +export interface VTermSpan { + text: string + fg: string | null + bg: string | null + flags: number + width: number +} + +export interface VTermLine { + spans: VTermSpan[] +} + +export interface VTermData { + cols: number + rows: number + cursor: [number, number] + offset: number + totalLines: number + lines: VTermLine[] +} + +function convertSpanToChunk(span: VTermSpan): TextChunk { + const { text, fg, bg, flags } = span + + let fgColor = fg ? RGBA.fromHex(fg) : DEFAULT_FG + let bgColor = bg ? RGBA.fromHex(bg) : undefined + + if (flags & VTermStyleFlags.INVERSE) { + const temp = fgColor + fgColor = bgColor || DEFAULT_FG + bgColor = temp + } + + let attributes = 0 + if (flags & VTermStyleFlags.BOLD) attributes |= TextAttributes.BOLD + if (flags & VTermStyleFlags.ITALIC) attributes |= TextAttributes.ITALIC + if (flags & VTermStyleFlags.UNDERLINE) attributes |= TextAttributes.UNDERLINE + if (flags & VTermStyleFlags.STRIKETHROUGH) attributes |= TextAttributes.STRIKETHROUGH + if (flags & VTermStyleFlags.FAINT) attributes |= TextAttributes.DIM + + return { __isChunk: true, text, fg: fgColor, bg: bgColor, attributes } +} + +export function vtermDataToStyledText(data: VTermData): StyledText { + const chunks: TextChunk[] = [] + + for (let i = 0; i < data.lines.length; i++) { + const line = data.lines[i] + + if (line.spans.length === 0) { + chunks.push({ __isChunk: true, text: " ", attributes: 0 }) + } else { + for (const span of line.spans) { + chunks.push(convertSpanToChunk(span)) + } + } + + if (i < data.lines.length - 1) { + chunks.push({ __isChunk: true, text: "\n", attributes: 0 }) + } + } + + return new StyledText(chunks) +} diff --git a/packages/core/src/renderables/Terminal.test.ts b/packages/core/src/renderables/Terminal.test.ts new file mode 100644 index 000000000..a892c5ae5 --- /dev/null +++ b/packages/core/src/renderables/Terminal.test.ts @@ -0,0 +1,763 @@ +import { test, expect, beforeEach, afterEach } from "bun:test" +import { TerminalRenderable, StatelessTerminalRenderable } from "./Terminal" +import { createTestRenderer, type TestRenderer } from "../testing" + +let currentRenderer: TestRenderer +let renderOnce: () => Promise +let captureFrame: () => string + +beforeEach(async () => { + const testRenderer = await createTestRenderer({ width: 80, height: 24 }) + currentRenderer = testRenderer.renderer + renderOnce = testRenderer.renderOnce + captureFrame = testRenderer.captureCharFrame +}) + +afterEach(async () => { + if (currentRenderer) { + currentRenderer.destroy() + } +}) + +test("TerminalRenderable - basic construction", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + expect(terminal.cols).toBe(80) + expect(terminal.rows).toBe(24) + + terminal.destroy() +}) + +test("TerminalRenderable - feed simple text", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + terminal.feed("Hello, World!") + + currentRenderer.root.add(terminal) + await renderOnce() + + const frame = captureFrame() + expect(frame).toContain("Hello, World!") + + terminal.destroy() +}) + +test("TerminalRenderable - feed ANSI colored text", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + // Feed red "Hello" and green "World" + terminal.feed("\x1b[31mHello\x1b[0m \x1b[32mWorld\x1b[0m") + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text).toContain("Hello") + expect(text).toContain("World") + + terminal.destroy() +}) + +test("TerminalRenderable - getCursor returns position", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + terminal.feed("ABC") + const cursor = terminal.getCursor() + + expect(cursor[0]).toBe(3) // x position after "ABC" + expect(cursor[1]).toBe(0) // y position (first row) + + terminal.destroy() +}) + +test("TerminalRenderable - reset clears content", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + terminal.feed("Some text that should be cleared") + terminal.reset() + + const cursor = terminal.getCursor() + expect(cursor[0]).toBe(0) + expect(cursor[1]).toBe(0) + + terminal.destroy() +}) + +test("StatelessTerminalRenderable - basic construction", async () => { + const terminal = new StatelessTerminalRenderable(currentRenderer, { + id: "test-stateless-terminal", + ansi: "Hello, World!", + cols: 80, + rows: 24, + }) + + expect(terminal.cols).toBe(80) + expect(terminal.rows).toBe(24) + + currentRenderer.root.add(terminal) + await renderOnce() + + const frame = captureFrame() + expect(frame).toContain("Hello, World!") +}) + +test("StatelessTerminalRenderable - ANSI colored text", async () => { + const terminal = new StatelessTerminalRenderable(currentRenderer, { + id: "test-stateless-terminal", + ansi: "\x1b[31mRed\x1b[0m \x1b[32mGreen\x1b[0m", + cols: 80, + rows: 24, + }) + + currentRenderer.root.add(terminal) + await renderOnce() + + const frame = captureFrame() + expect(frame).toContain("Red") + expect(frame).toContain("Green") +}) + +test("TerminalRenderable - multiple feeds", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + terminal.feed("Line 1\n") + terminal.feed("Line 2\n") + terminal.feed("Line 3") + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text).toContain("Line 1") + expect(text).toContain("Line 2") + expect(text).toContain("Line 3") + + terminal.destroy() +}) + +test("TerminalRenderable - isReady returns correct state", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + terminal.feed("Hello") + expect(terminal.isReady()).toBe(true) + + terminal.destroy() +}) + +// Large input tests to reproduce potential segfaults + +function generateLargeAnsi(lineCount: number, lineLength: number = 80): string { + const colors = [31, 32, 33, 34, 35, 36, 37] + let result = "" + for (let i = 0; i < lineCount; i++) { + const color = colors[i % colors.length] + const text = `Line ${i}: ${"x".repeat(lineLength - 10)}` + result += `\x1b[${color}m${text}\x1b[0m\n` + } + return result +} + +function generateComplexAnsi(size: number): string { + let result = "" + const styles = [ + "\x1b[1m", // bold + "\x1b[2m", // dim + "\x1b[3m", // italic + "\x1b[4m", // underline + "\x1b[7m", // inverse + "\x1b[9m", // strikethrough + "\x1b[31m", // red + "\x1b[32m", // green + "\x1b[33m", // yellow + "\x1b[34m", // blue + "\x1b[38;5;208m", // 256 color + "\x1b[38;2;255;105;180m", // RGB color + ] + + let currentSize = 0 + let lineNum = 0 + while (currentSize < size) { + const style = styles[lineNum % styles.length] + const line = `${style}Line ${lineNum}: Some text content here\x1b[0m\n` + result += line + currentSize += line.length + lineNum++ + } + return result +} + +test("TerminalRenderable - large input 1000 lines", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-large", + cols: 120, + rows: 50, + }) + + const largeAnsi = generateLargeAnsi(1000) + terminal.feed(largeAnsi) + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text).toContain("Line 0") + expect(text).toContain("Line 999") + + terminal.destroy() +}) + +test("TerminalRenderable - large input 10000 lines", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-large", + cols: 120, + rows: 50, + }) + + const largeAnsi = generateLargeAnsi(10000) + terminal.feed(largeAnsi) + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text).toContain("Line 0") + expect(text).toContain("Line 9999") + + terminal.destroy() +}) + +test("TerminalRenderable - very large input 100KB", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-100kb", + cols: 120, + rows: 50, + }) + + const largeAnsi = generateComplexAnsi(100 * 1024) + terminal.feed(largeAnsi) + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text.length).toBeGreaterThan(0) + + terminal.destroy() +}) + +test("TerminalRenderable - very large input 200KB", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-200kb", + cols: 120, + rows: 50, + }) + + const largeAnsi = generateComplexAnsi(200 * 1024) + terminal.feed(largeAnsi) + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text.length).toBeGreaterThan(0) + + terminal.destroy() +}) + +test("StatelessTerminalRenderable - large input 1000 lines", async () => { + const largeAnsi = generateLargeAnsi(1000) + + const terminal = new StatelessTerminalRenderable(currentRenderer, { + id: "test-stateless-large", + ansi: largeAnsi, + cols: 120, + rows: 50, + }) + + currentRenderer.root.add(terminal) + await renderOnce() + + const frame = captureFrame() + expect(frame.length).toBeGreaterThan(0) +}) + +test("StatelessTerminalRenderable - large input 200KB", async () => { + const largeAnsi = generateComplexAnsi(200 * 1024) + + const terminal = new StatelessTerminalRenderable(currentRenderer, { + id: "test-stateless-200kb", + ansi: largeAnsi, + cols: 120, + rows: 50, + }) + + currentRenderer.root.add(terminal) + await renderOnce() + + const frame = captureFrame() + expect(frame.length).toBeGreaterThan(0) +}) + +test("TerminalRenderable - create and destroy many terminals", async () => { + for (let i = 0; i < 20; i++) { + const terminal = new TerminalRenderable(currentRenderer, { + id: `test-terminal-${i}`, + cols: 80, + rows: 24, + }) + + terminal.feed(`Terminal ${i}: \x1b[32mSome colored text\x1b[0m\n`) + + currentRenderer.root.add(terminal) + await renderOnce() + + terminal.destroy() + currentRenderer.root.remove(`test-terminal-${i}`) + } + + expect(true).toBe(true) +}) + +test("TerminalRenderable - concurrent terminals", async () => { + const terminals: TerminalRenderable[] = [] + + // Create multiple terminals + for (let i = 0; i < 5; i++) { + const terminal = new TerminalRenderable(currentRenderer, { + id: `test-concurrent-${i}`, + cols: 80, + rows: 24, + }) + terminals.push(terminal) + currentRenderer.root.add(terminal) + } + + // Feed data to all of them + for (let j = 0; j < 10; j++) { + for (let i = 0; i < terminals.length; i++) { + terminals[i].feed(`\x1b[${31 + i}mTerminal ${i}, line ${j}\x1b[0m\n`) + } + await renderOnce() + } + + // Destroy all + for (const terminal of terminals) { + terminal.destroy() + } + + expect(true).toBe(true) +}) + +test("TerminalRenderable - resize during feed", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-resize", + cols: 80, + rows: 24, + }) + + currentRenderer.root.add(terminal) + + for (let i = 0; i < 10; i++) { + terminal.feed(`Line ${i}: Some content\n`) + terminal.cols = 80 + i * 10 + terminal.rows = 24 + i * 2 + await renderOnce() + } + + expect(terminal.cols).toBe(170) + expect(terminal.rows).toBe(42) + + terminal.destroy() +}) + +test("TerminalRenderable - reset and refeed", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-reset", + cols: 80, + rows: 24, + }) + + currentRenderer.root.add(terminal) + + for (let i = 0; i < 5; i++) { + terminal.feed(generateLargeAnsi(100)) + await renderOnce() + terminal.reset() + await renderOnce() + } + + const cursor = terminal.getCursor() + expect(cursor[0]).toBe(0) + expect(cursor[1]).toBe(0) + + terminal.destroy() +}) + +test("TerminalRenderable - special escape sequences", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-special", + cols: 80, + rows: 24, + }) + + // Various special sequences + const sequences = [ + "\x1b[2J", // Clear screen + "\x1b[H", // Home + "\x1b[K", // Clear to end of line + "\x1b[1K", // Clear to beginning of line + "\x1b[2K", // Clear entire line + "\x1b[J", // Clear to end of screen + "\x1b[1J", // Clear to beginning of screen + "\x1b[s", // Save cursor + "\x1b[u", // Restore cursor + "\x1b[?25l", // Hide cursor + "\x1b[?25h", // Show cursor + "\x1b[0m", // Reset attributes + "\x1b[1;1H", // Move to 1,1 + "\x1b[10;20H", // Move to 10,20 + ] + + for (const seq of sequences) { + terminal.feed(seq + "Some text after sequence\n") + } + + currentRenderer.root.add(terminal) + await renderOnce() + + expect(true).toBe(true) + + terminal.destroy() +}) + +test("TerminalRenderable - binary/control characters", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-binary", + cols: 80, + rows: 24, + }) + + // Feed some binary/control characters + let binaryData = "" + for (let i = 0; i < 32; i++) { + if (i !== 27) { + // Skip ESC + binaryData += String.fromCharCode(i) + } + } + binaryData += "Normal text after binary\n" + + terminal.feed(binaryData) + + currentRenderer.root.add(terminal) + await renderOnce() + + expect(true).toBe(true) + + terminal.destroy() +}) + +// Tests to reproduce async/microtask segfaults + +test("TerminalRenderable - rapid async getText calls", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-async", + cols: 80, + rows: 24, + }) + + terminal.feed(generateLargeAnsi(500)) + currentRenderer.root.add(terminal) + + // Make many rapid getText calls with microtask breaks + const results: string[] = [] + for (let i = 0; i < 50; i++) { + const text = terminal.getText() + results.push(text) + await Promise.resolve() // Force microtask break + } + + expect(results.length).toBe(50) + expect(results.every((r) => r.length > 0)).toBe(true) + + terminal.destroy() +}) + +test("TerminalRenderable - rapid async getCursor calls", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-cursor-async", + cols: 80, + rows: 24, + }) + + terminal.feed(generateLargeAnsi(500)) + currentRenderer.root.add(terminal) + + // Make many rapid getCursor calls with microtask breaks + const results: [number, number][] = [] + for (let i = 0; i < 50; i++) { + const cursor = terminal.getCursor() + results.push(cursor) + await Promise.resolve() // Force microtask break + } + + expect(results.length).toBe(50) + + terminal.destroy() +}) + +test("StatelessTerminalRenderable - rapid ansi updates with microtasks", async () => { + const terminal = new StatelessTerminalRenderable(currentRenderer, { + id: "test-stateless-async", + ansi: "Initial", + cols: 80, + rows: 24, + }) + + currentRenderer.root.add(terminal) + + // Rapidly update ansi with microtask breaks + for (let i = 0; i < 100; i++) { + terminal.ansi = generateLargeAnsi(50) + await renderOnce() + await Promise.resolve() // Force microtask break + } + + expect(true).toBe(true) +}) + +test("TerminalRenderable - interleaved feed/getText/render", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-interleaved", + cols: 80, + rows: 24, + }) + + currentRenderer.root.add(terminal) + + for (let i = 0; i < 100; i++) { + terminal.feed(`\x1b[${31 + (i % 7)}mLine ${i}\x1b[0m\n`) + const text = terminal.getText() + await renderOnce() + const cursor = terminal.getCursor() + await Promise.resolve() + } + + terminal.destroy() +}) + +test("TerminalRenderable - parallel getText and feed", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-parallel", + cols: 80, + rows: 24, + }) + + terminal.feed(generateLargeAnsi(100)) + currentRenderer.root.add(terminal) + + // Create multiple promises that read text + const promises: Promise[] = [] + for (let i = 0; i < 20; i++) { + promises.push( + new Promise((resolve) => { + setTimeout(() => { + resolve(terminal.getText()) + }, i * 10) + }), + ) + } + + // Also feed more data while reading + for (let i = 0; i < 10; i++) { + terminal.feed(`\x1b[32mMore data ${i}\x1b[0m\n`) + await Promise.resolve() + } + + const results = await Promise.all(promises) + expect(results.every((r) => typeof r === "string")).toBe(true) + + terminal.destroy() +}) + +test("TerminalRenderable - stress test create/destroy/getText cycle", async () => { + for (let i = 0; i < 50; i++) { + const terminal = new TerminalRenderable(currentRenderer, { + id: `test-terminal-stress-${i}`, + cols: 80, + rows: 24, + }) + + terminal.feed(generateLargeAnsi(100)) + currentRenderer.root.add(terminal) + await renderOnce() + + // Get text multiple times + terminal.getText() + terminal.getText() + terminal.getText() + + await Promise.resolve() + + terminal.destroy() + currentRenderer.root.remove(`test-terminal-stress-${i}`) + + await Promise.resolve() + } + + expect(true).toBe(true) +}) + +test("StatelessTerminalRenderable - stress test rapid creation", async () => { + const terminals: StatelessTerminalRenderable[] = [] + + for (let i = 0; i < 30; i++) { + const terminal = new StatelessTerminalRenderable(currentRenderer, { + id: `test-stateless-stress-${i}`, + ansi: generateLargeAnsi(100), + cols: 80, + rows: 24, + }) + terminals.push(terminal) + currentRenderer.root.add(terminal) + } + + await renderOnce() + + // Access all terminals + for (const terminal of terminals) { + await Promise.resolve() + } + + // Destroy all + for (let i = 0; i < terminals.length; i++) { + currentRenderer.root.remove(`test-stateless-stress-${i}`) + } + + await Promise.resolve() + expect(true).toBe(true) +}) + +test("TerminalRenderable - multiple large feeds", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-multi-feed", + cols: 120, + rows: 50, + }) + + // Feed in chunks + for (let i = 0; i < 10; i++) { + const chunk = generateLargeAnsi(100) + terminal.feed(chunk) + } + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text.length).toBeGreaterThan(0) + + terminal.destroy() +}) + +test("TerminalRenderable - rapid feed and render cycles", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-rapid", + cols: 120, + rows: 50, + }) + + currentRenderer.root.add(terminal) + + // Rapid feed and render + for (let i = 0; i < 50; i++) { + terminal.feed(`\x1b[${31 + (i % 7)}mLine ${i}: Some content here\x1b[0m\n`) + await renderOnce() + } + + const text = terminal.getText() + expect(text).toContain("Line 49") + + terminal.destroy() +}) + +test("TerminalRenderable - feed with cursor movement sequences", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-cursor", + cols: 80, + rows: 24, + }) + + // Various cursor movement and control sequences + const ansi = + `\x1b[2J\x1b[H` + // Clear screen and home + `\x1b[5;10HPosition 5,10` + // Move to row 5, col 10 + `\x1b[10;20HPosition 10,20` + // Move to row 10, col 20 + `\x1b[A\x1b[A\x1b[A` + // Move up 3 times + `After moving up` + + `\x1b[B\x1b[B` + // Move down 2 times + `After moving down` + + `\x1b[C\x1b[C\x1b[C` + // Move right 3 times + `After moving right` + + `\x1b[D\x1b[D` + // Move left 2 times + `After moving left` + + terminal.feed(ansi) + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text).toContain("Position") + + terminal.destroy() +}) + +test("TerminalRenderable - large input with scrollback", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-scrollback", + cols: 80, + rows: 24, + }) + + // Generate more lines than rows to test scrollback + const ansi = generateLargeAnsi(1000, 70) + terminal.feed(ansi) + + currentRenderer.root.add(terminal) + await renderOnce() + + // Check that content exists + const text = terminal.getText() + expect(text.length).toBeGreaterThan(0) + + terminal.destroy() +}) diff --git a/packages/core/src/renderables/Terminal.ts b/packages/core/src/renderables/Terminal.ts new file mode 100644 index 000000000..beb290353 --- /dev/null +++ b/packages/core/src/renderables/Terminal.ts @@ -0,0 +1,388 @@ +import { TextBufferRenderable, type TextBufferOptions } from "./TextBufferRenderable" +import { RGBA } from "../lib/RGBA" +import type { RenderContext } from "../types" +import type { OptimizedBuffer } from "../buffer" +import { resolveRenderLib, type RenderLib } from "../zig" +import { vtermDataToStyledText, type VTermData } from "../lib/vterm-ffi" +import type { MouseEvent } from "../renderer" + +// Re-export types from vterm-ffi for backwards compatibility +export { + VTermStyleFlags, + type VTermSpan, + type VTermLine, + type VTermData, + vtermDataToStyledText, +} from "../lib/vterm-ffi" + +const DEFAULT_FG = RGBA.fromHex("#d4d4d4") + +function trimEmptyLines(data: VTermData): void { + while (data.lines.length > 0) { + const lastLine = data.lines[data.lines.length - 1] + const hasText = lastLine.spans.some((span) => span.text.trim().length > 0) + if (hasText) break + data.lines.pop() + } +} + +export interface StatelessTerminalOptions extends TextBufferOptions { + ansi?: string | Buffer + cols?: number + rows?: number + limit?: number + trimEnd?: boolean +} + +export class StatelessTerminalRenderable extends TextBufferRenderable { + private _ansi: string | Buffer + private _cols: number + private _rows: number + private _limit?: number + private _trimEnd?: boolean + private _needsUpdate: boolean = true + private _lineCount: number = 0 + private _lib: RenderLib + + constructor(ctx: RenderContext, options: StatelessTerminalOptions) { + super(ctx, { ...options, fg: DEFAULT_FG, wrapMode: "none" }) + this._ansi = options.ansi ?? "" + this._cols = options.cols ?? 120 + this._rows = options.rows ?? 40 + this._limit = options.limit + this._trimEnd = options.trimEnd + this._lib = resolveRenderLib() + } + + get lineCount(): number { + return this._lineCount + } + + get ansi(): string | Buffer { + return this._ansi + } + + set ansi(value: string | Buffer) { + if (this._ansi !== value) { + this._ansi = value + this._needsUpdate = true + this.requestRender() + } + } + + get cols(): number { + return this._cols + } + + set cols(value: number) { + if (this._cols !== value) { + this._cols = value + this._needsUpdate = true + this.requestRender() + } + } + + get rows(): number { + return this._rows + } + + set rows(value: number) { + if (this._rows !== value) { + this._rows = value + this._needsUpdate = true + this.requestRender() + } + } + + get limit(): number | undefined { + return this._limit + } + + set limit(value: number | undefined) { + if (this._limit !== value) { + this._limit = value + this._needsUpdate = true + this.requestRender() + } + } + + get trimEnd(): boolean | undefined { + return this._trimEnd + } + + set trimEnd(value: boolean | undefined) { + if (this._trimEnd !== value) { + this._trimEnd = value + this._needsUpdate = true + this.requestRender() + } + } + + protected renderSelf(buffer: OptimizedBuffer): void { + if (this._needsUpdate) { + const data = this._lib.vtermPtyToJson(this._ansi, { + cols: this._cols, + rows: this._rows, + limit: this._limit, + }) as VTermData + + if (this._trimEnd) trimEmptyLines(data) + + this.textBuffer.setStyledText(vtermDataToStyledText(data)) + this.updateTextInfo() + this._lineCount = this.textBufferView.logicalLineInfo.lineStarts.length + this._needsUpdate = false + } + super.renderSelf(buffer) + } + + getScrollPositionForLine(lineNumber: number): number { + const clampedLine = Math.max(0, Math.min(lineNumber, this._lineCount - 1)) + const lineStarts = this.textBufferView.logicalLineInfo.lineStarts + const lineYOffset = lineStarts?.[clampedLine] ?? clampedLine + return this.y + lineYOffset + } +} + +export interface TerminalOptions extends TextBufferOptions { + cols?: number + rows?: number + trimEnd?: boolean + readable?: ReadableStream + writable?: WritableStream +} + +let nextTerminalId = 1 + +export class TerminalRenderable extends TextBufferRenderable { + private _cols: number + private _rows: number + private _trimEnd?: boolean + private _contentDirty: boolean = true + private _lineCount: number = 0 + private _terminalId: number + private _lib: RenderLib + private _destroyed = false + private _readable?: ReadableStream + private _writable?: WritableStream + private _reader?: ReadableStreamDefaultReader + private _writer?: WritableStreamDefaultWriter + + constructor(ctx: RenderContext, options: TerminalOptions) { + super(ctx, { ...options, fg: DEFAULT_FG, wrapMode: "none" }) + + this._cols = options.cols ?? 120 + this._rows = options.rows ?? 40 + this._trimEnd = options.trimEnd + this._lib = resolveRenderLib() + this._terminalId = nextTerminalId++ + + const success = this._lib.vtermCreateTerminal(this._terminalId, this._cols, this._rows) + if (!success) { + throw new Error("Failed to create terminal") + } + + if (options.readable) { + this._readable = options.readable + this._reader = options.readable.getReader() + this.startReading() + } + + if (options.writable) { + this._writable = options.writable + this._writer = options.writable.getWriter() + } + } + + private async startReading(): Promise { + if (!this._reader) return + + try { + while (!this._destroyed) { + const { done, value } = await this._reader.read() + if (done || this._destroyed) break + if (value) { + this._lib.vtermFeedTerminal(this._terminalId, value) + this._contentDirty = true + this.requestRender() + } + } + } catch { + // Stream closed or errored + } + } + + get lineCount(): number { + return this._lineCount + } + + get cols(): number { + return this._cols + } + + set cols(value: number) { + if (this._cols !== value) { + this._cols = value + this._lib.vtermResizeTerminal(this._terminalId, value, this._rows) + this._contentDirty = true + this.requestRender() + } + } + + get rows(): number { + return this._rows + } + + set rows(value: number) { + if (this._rows !== value) { + this._rows = value + this._lib.vtermResizeTerminal(this._terminalId, this._cols, value) + this._contentDirty = true + this.requestRender() + } + } + + get trimEnd(): boolean | undefined { + return this._trimEnd + } + + set trimEnd(value: boolean | undefined) { + if (this._trimEnd !== value) { + this._trimEnd = value + this._contentDirty = true + this.requestRender() + } + } + + get readable(): ReadableStream | undefined { + return this._readable + } + + set readable(value: ReadableStream | undefined) { + if (value === this._readable) return + if (this._readable && value) { + throw new Error("TerminalRenderable: changing readable stream is not supported") + } + this._readable = value + if (value) { + this._reader = value.getReader() + this.startReading() + } + } + + get writable(): WritableStream | undefined { + return this._writable + } + + set writable(value: WritableStream | undefined) { + if (value === this._writable) return + if (this._writable && value) { + throw new Error("TerminalRenderable: changing writable stream is not supported") + } + this._writable = value + if (value) { + this._writer = value.getWriter() + } + } + + feed(data: string | Buffer): void { + this._lib.vtermFeedTerminal(this._terminalId, data) + this._contentDirty = true + this.requestRender() + } + + reset(): void { + this._lib.vtermResetTerminal(this._terminalId) + this._contentDirty = true + this.requestRender() + } + + getCursor(): [number, number] { + return this._lib.vtermGetTerminalCursor(this._terminalId) + } + + getText(): string { + return this._lib.vtermGetTerminalText(this._terminalId) + } + + isReady(): boolean { + return this._lib.vtermIsTerminalReady(this._terminalId) + } + + protected override onMouseEvent(event: MouseEvent): void { + super.onMouseEvent(event) + if (!this._writer) return + + const { x, y, type, button, modifiers, scroll } = event + + // Check bounds + if (x < this.x || x >= this.x + this.width || y < this.y || y >= this.y + this.height) return + + // Transform to 1-based terminal coordinates + const col = x - this.x + 1 + const row = y - this.y + 1 + + let encoded: string | null = null + + if (scroll) { + // Scroll: button 64=up, 65=down, 66=left, 67=right + const buttonMap = { up: 64, down: 65, left: 66, right: 67 } + const scrollBtn = buttonMap[scroll.direction] + encoded = this.encodeMouse("press", scrollBtn, col, row, modifiers) + } else { + // Mouse events + let encodeType: "press" | "release" | "move" | null = null + if (type === "down") encodeType = "press" + else if (type === "up") encodeType = "release" + else if (type === "move" || type === "drag") encodeType = "move" + + if (encodeType) { + encoded = this.encodeMouse(encodeType, button, col, row, modifiers) + } + } + + if (encoded) { + this._writer.write(encoded) + } + } + + private encodeMouse( + type: "press" | "release" | "move", + button: number, + col: number, + row: number, + modifiers?: { shift: boolean; alt: boolean; ctrl: boolean }, + ): string { + let btn = button + if (modifiers?.shift) btn |= 4 + if (modifiers?.alt) btn |= 8 + if (modifiers?.ctrl) btn |= 16 + if (type === "move") btn |= 32 + const suffix = type === "release" ? "m" : "M" + return `\x1b[<${btn};${col};${row}${suffix}` + } + + destroy(): void { + if (!this._destroyed) { + this._destroyed = true + this._reader?.cancel() + this._writer?.close() + this._lib.vtermDestroyTerminal(this._terminalId) + } + super.destroy() + } + + protected renderSelf(buffer: OptimizedBuffer): void { + if (this._contentDirty && !this._destroyed) { + const data = this._lib.vtermGetTerminalJson(this._terminalId, {}) as VTermData + + if (this._trimEnd) trimEmptyLines(data) + + this.textBuffer.setStyledText(vtermDataToStyledText(data)) + this.updateTextInfo() + this._lineCount = this.textBufferView.logicalLineInfo.lineStarts.length + this._contentDirty = false + } + super.renderSelf(buffer) + } +} diff --git a/packages/core/src/renderables/index.ts b/packages/core/src/renderables/index.ts index 2abdd9827..bf671919e 100644 --- a/packages/core/src/renderables/index.ts +++ b/packages/core/src/renderables/index.ts @@ -13,6 +13,7 @@ export * from "./ScrollBox" export * from "./Select" export * from "./Slider" export * from "./TabSelect" +export * from "./Terminal" export * from "./Text" export * from "./TextBufferRenderable" export * from "./TextNode" diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 267870a30..b4ce0a1c0 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -948,6 +948,61 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "u32", "u32", "u32", "ptr", "ptr", "u32"], returns: "void", }, + + // VTerm functions - use caller-provides-buffer pattern like rest of codebase + vtermPtyToJson: { + args: [ + "ptr", + "usize" as const, + "u16", + "u16", + "usize" as const, + "usize" as const, + "ptr", + "usize" as const, + ] as const, + returns: "usize" as const, + }, + vtermPtyToText: { + args: ["ptr", "usize" as const, "u16", "u16", "ptr", "usize" as const] as const, + returns: "usize" as const, + }, + vtermCreateTerminal: { + args: ["u32", "u32", "u32"] as const, + returns: "bool", + }, + vtermDestroyTerminal: { + args: ["u32"] as const, + returns: "void", + }, + vtermFeedTerminal: { + args: ["u32", "ptr", "usize" as const] as const, + returns: "bool", + }, + vtermResizeTerminal: { + args: ["u32", "u32", "u32"] as const, + returns: "bool", + }, + vtermResetTerminal: { + args: ["u32"] as const, + returns: "bool", + }, + vtermGetTerminalJson: { + args: ["u32", "u32", "u32", "ptr", "usize" as const] as const, + returns: "usize" as const, + }, + vtermGetTerminalText: { + args: ["u32", "ptr", "usize" as const] as const, + returns: "usize" as const, + }, + vtermGetTerminalCursor: { + args: ["u32", "ptr", "usize" as const] as const, + returns: "usize" as const, + }, + vtermIsTerminalReady: { + args: ["u32"] as const, + returns: "i32", + }, }) if (env.OTUI_DEBUG_FFI || env.OTUI_TRACE_FFI) { @@ -1574,6 +1629,22 @@ export interface RenderLib { onceNativeEvent: (name: string, handler: (data: ArrayBuffer) => void) => void offNativeEvent: (name: string, handler: (data: ArrayBuffer) => void) => void onAnyNativeEvent: (handler: (name: string, data: ArrayBuffer) => void) => void + + // VTerm functions + vtermPtyToJson: ( + input: Buffer | Uint8Array | string, + options?: { cols?: number; rows?: number; offset?: number; limit?: number }, + ) => any + vtermPtyToText: (input: Buffer | Uint8Array | string, options?: { cols?: number; rows?: number }) => string + vtermCreateTerminal: (id: number, cols: number, rows: number) => boolean + vtermDestroyTerminal: (id: number) => void + vtermFeedTerminal: (id: number, data: Buffer | Uint8Array | string) => boolean + vtermResizeTerminal: (id: number, cols: number, rows: number) => boolean + vtermResetTerminal: (id: number) => boolean + vtermGetTerminalJson: (id: number, options?: { offset?: number; limit?: number }) => any + vtermGetTerminalText: (id: number) => string + vtermGetTerminalCursor: (id: number) => [number, number] + vtermIsTerminalReady: (id: number) => boolean } class FFIRenderLib implements RenderLib { @@ -3261,6 +3332,221 @@ class FFIRenderLib implements RenderLib { public onAnyNativeEvent(handler: (name: string, data: ArrayBuffer) => void): void { this._anyEventHandlers.push(handler) } + + // VTerm methods - use caller-provides-buffer pattern like rest of codebase + + // Reusable buffer for vterm output (avoids 4MB allocation per call) + private vtermBuffer: Uint8Array | null = null + private readonly vtermBufferSize = 4 * 1024 * 1024 + + private getVtermBuffer(): Uint8Array { + if (!this.vtermBuffer) { + this.vtermBuffer = new Uint8Array(this.vtermBufferSize) + } + return this.vtermBuffer + } + + public vtermPtyToJson( + input: Buffer | Uint8Array | string, + options: { cols?: number; rows?: number; offset?: number; limit?: number } = {}, + ): any { + const { cols = 120, rows = 40, offset = 0, limit = 0 } = options + + const inputStr = typeof input === "string" ? input : input.toString("utf-8") + + if (inputStr.length === 0) { + return { + cols, + rows, + cursor: [0, 0], + offset, + totalLines: 0, + lines: [], + } + } + + const inputBuffer = Buffer.from(inputStr) + const outBuffer = this.getVtermBuffer() + + const actualLen = this.opentui.symbols.vtermPtyToJson( + ptr(inputBuffer), + inputBuffer.length, + cols, + rows, + offset, + limit, + ptr(outBuffer), + outBuffer.length, + ) + + const len = typeof actualLen === "bigint" ? Number(actualLen) : actualLen + if (len === 0) { + throw new Error("VTerm ptyToJson failed or output exceeded buffer size") + } + + const jsonStr = this.decoder.decode(outBuffer.subarray(0, len)) + + const raw = JSON.parse(jsonStr) as { + cols: number + rows: number + cursor: [number, number] + offset: number + totalLines: number + lines: Array> + } + + return { + cols: raw.cols, + rows: raw.rows, + cursor: raw.cursor, + offset: raw.offset, + totalLines: raw.totalLines, + lines: raw.lines.map((line) => ({ + spans: line.map(([text, fg, bg, flags, width]) => ({ + text, + fg, + bg, + flags, + width, + })), + })), + } + } + + public vtermPtyToText(input: Buffer | Uint8Array | string, options: { cols?: number; rows?: number } = {}): string { + const { cols = 500, rows = 256 } = options + + const inputStr = typeof input === "string" ? input : input.toString("utf-8") + + if (inputStr.length === 0) { + return "" + } + + const inputBuffer = Buffer.from(inputStr) + const outBuffer = this.getVtermBuffer() + + const actualLen = this.opentui.symbols.vtermPtyToText( + ptr(inputBuffer), + inputBuffer.length, + cols, + rows, + ptr(outBuffer), + outBuffer.length, + ) + + const len = typeof actualLen === "bigint" ? Number(actualLen) : actualLen + if (len === 0) { + return "" + } + + return this.decoder.decode(outBuffer.subarray(0, len)) + } + + public vtermCreateTerminal(id: number, cols: number, rows: number): boolean { + return this.opentui.symbols.vtermCreateTerminal(id, cols, rows) + } + + public vtermDestroyTerminal(id: number): void { + this.opentui.symbols.vtermDestroyTerminal(id) + } + + public vtermFeedTerminal(id: number, data: Buffer | Uint8Array | string): boolean { + let str: string + if (typeof data === "string") { + str = data + } else if (Buffer.isBuffer(data)) { + str = data.toString("utf-8") + } else { + str = new TextDecoder("utf-8").decode(data) + } + + const buffer = Buffer.from(str) + return this.opentui.symbols.vtermFeedTerminal(id, ptr(buffer), buffer.length) + } + + public vtermResizeTerminal(id: number, cols: number, rows: number): boolean { + return this.opentui.symbols.vtermResizeTerminal(id, cols, rows) + } + + public vtermResetTerminal(id: number): boolean { + return this.opentui.symbols.vtermResetTerminal(id) + } + + public vtermGetTerminalJson(id: number, options: { offset?: number; limit?: number } = {}): any { + const { offset = 0, limit = 0 } = options + + const outBuffer = this.getVtermBuffer() + + const actualLen = this.opentui.symbols.vtermGetTerminalJson(id, offset, limit, ptr(outBuffer), outBuffer.length) + + const len = typeof actualLen === "bigint" ? Number(actualLen) : actualLen + if (len === 0) { + throw new Error("Failed to get terminal JSON - terminal may not exist or output exceeded buffer") + } + + const jsonStr = this.decoder.decode(outBuffer.subarray(0, len)) + const raw = JSON.parse(jsonStr) as { + cols: number + rows: number + cursor: [number, number] + offset: number + totalLines: number + lines: Array> + } + + return { + cols: raw.cols, + rows: raw.rows, + cursor: raw.cursor, + offset: raw.offset, + totalLines: raw.totalLines, + lines: raw.lines.map((line) => ({ + spans: line.map(([text, fg, bg, flags, width]) => ({ + text, + fg, + bg, + flags, + width, + })), + })), + } + } + + public vtermGetTerminalText(id: number): string { + const outBuffer = this.getVtermBuffer() + + const actualLen = this.opentui.symbols.vtermGetTerminalText(id, ptr(outBuffer), outBuffer.length) + + const len = typeof actualLen === "bigint" ? Number(actualLen) : actualLen + if (len === 0) { + return "" + } + + return this.decoder.decode(outBuffer.subarray(0, len)) + } + + public vtermGetTerminalCursor(id: number): [number, number] { + // Cursor JSON is small: "[x,y]" - 32 bytes is plenty + const outBuffer = new Uint8Array(32) + + const actualLen = this.opentui.symbols.vtermGetTerminalCursor(id, ptr(outBuffer), outBuffer.length) + + const len = typeof actualLen === "bigint" ? Number(actualLen) : actualLen + if (len === 0) { + throw new Error("Failed to get terminal cursor - terminal may not exist") + } + + const jsonStr = this.decoder.decode(outBuffer.subarray(0, len)) + return JSON.parse(jsonStr) as [number, number] + } + + public vtermIsTerminalReady(id: number): boolean { + const result = this.opentui.symbols.vtermIsTerminalReady(id) + if (result === -1) { + throw new Error("Failed to check terminal ready state - terminal may not exist") + } + return result === 1 + } } let opentuiLibPath: string | undefined diff --git a/packages/core/src/zig/ansi.zig b/packages/core/src/zig/ansi.zig index 12a6edeec..bef215abd 100644 --- a/packages/core/src/zig/ansi.zig +++ b/packages/core/src/zig/ansi.zig @@ -21,15 +21,15 @@ pub const ANSI = struct { // Direct writing to any writer - the most efficient option pub fn moveToOutput(writer: anytype, x: u32, y: u32) AnsiError!void { - std.fmt.format(writer, "\x1b[{d};{d}H", .{ y, x }) catch return AnsiError.WriteFailed; + writer.print("\x1b[{d};{d}H", .{ y, x }) catch return AnsiError.WriteFailed; } pub fn fgColorOutput(writer: anytype, r: u8, g: u8, b: u8) AnsiError!void { - std.fmt.format(writer, "\x1b[38;2;{d};{d};{d}m", .{ r, g, b }) catch return AnsiError.WriteFailed; + writer.print("\x1b[38;2;{d};{d};{d}m", .{ r, g, b }) catch return AnsiError.WriteFailed; } pub fn bgColorOutput(writer: anytype, r: u8, g: u8, b: u8) AnsiError!void { - std.fmt.format(writer, "\x1b[48;2;{d};{d};{d}m", .{ r, g, b }) catch return AnsiError.WriteFailed; + writer.print("\x1b[48;2;{d};{d};{d}m", .{ r, g, b }) catch return AnsiError.WriteFailed; } // Text attribute constants @@ -51,11 +51,11 @@ pub const ANSI = struct { pub const cursorUnderlineBlink = "\x1b[3 q"; pub fn cursorColorOutputWriter(writer: anytype, r: u8, g: u8, b: u8) AnsiError!void { - std.fmt.format(writer, "\x1b]12;#{x:0>2}{x:0>2}{x:0>2}\x07", .{ r, g, b }) catch return AnsiError.WriteFailed; + writer.print("\x1b]12;#{x:0>2}{x:0>2}{x:0>2}\x07", .{ r, g, b }) catch return AnsiError.WriteFailed; } pub fn explicitWidthOutput(writer: anytype, width: u32, text: []const u8) AnsiError!void { - std.fmt.format(writer, "\x1b]66;w={d};{s}\x1b\\", .{ width, text }) catch return AnsiError.WriteFailed; + writer.print("\x1b]66;w={d};{s}\x1b\\", .{ width, text }) catch return AnsiError.WriteFailed; } pub const resetCursorColor = "\x1b]112\x07"; @@ -131,12 +131,15 @@ pub const ANSI = struct { pub const setTerminalTitle = "\x1b]0;{s}\x07"; pub fn setTerminalTitleOutput(writer: anytype, title: []const u8) AnsiError!void { - std.fmt.format(writer, setTerminalTitle, .{title}) catch return AnsiError.WriteFailed; + writer.print(setTerminalTitle, .{title}) catch return AnsiError.WriteFailed; } pub fn makeRoomForRendererOutput(writer: anytype, height: u32) AnsiError!void { if (height > 1) { - writer.writeByteNTimes('\n', height - 1) catch return AnsiError.WriteFailed; + var i: u32 = 0; + while (i < height - 1) : (i += 1) { + writer.writeByte('\n') catch return AnsiError.WriteFailed; + } } } }; diff --git a/packages/core/src/zig/buffer.zig b/packages/core/src/zig/buffer.zig index b67d9bda5..3265c333e 100644 --- a/packages/core/src/zig/buffer.zig +++ b/packages/core/src/zig/buffer.zig @@ -138,8 +138,8 @@ pub const OptimizedBuffer = struct { link_tracker: link.LinkTracker, width_method: utf8.WidthMethod, id: []const u8, - scissor_stack: std.ArrayList(ClipRect), - opacity_stack: std.ArrayList(f32), + scissor_stack: std.ArrayListUnmanaged(ClipRect), + opacity_stack: std.ArrayListUnmanaged(f32), const InitOptions = struct { respectAlpha: bool = false, @@ -163,11 +163,11 @@ pub const OptimizedBuffer = struct { const owned_id = allocator.dupe(u8, options.id) catch return BufferError.OutOfMemory; errdefer allocator.free(owned_id); - var scissor_stack = std.ArrayList(ClipRect).init(allocator); - errdefer scissor_stack.deinit(); + var scissor_stack: std.ArrayListUnmanaged(ClipRect) = .{}; + errdefer scissor_stack.deinit(allocator); - var opacity_stack = std.ArrayList(f32).init(allocator); - errdefer opacity_stack.deinit(); + var opacity_stack: std.ArrayListUnmanaged(f32) = .{}; + errdefer opacity_stack.deinit(allocator); const lp = options.link_pool orelse link.initGlobalLinkPool(allocator); @@ -217,8 +217,8 @@ pub const OptimizedBuffer = struct { } pub fn deinit(self: *OptimizedBuffer) void { - self.opacity_stack.deinit(); - self.scissor_stack.deinit(); + self.opacity_stack.deinit(self.allocator); + self.scissor_stack.deinit(self.allocator); self.link_tracker.deinit(); self.grapheme_tracker.deinit(); self.allocator.free(self.buffer.char); @@ -301,7 +301,7 @@ pub const OptimizedBuffer = struct { } } - try self.scissor_stack.append(rect); + try self.scissor_stack.append(self.allocator, rect); } pub fn popScissorRect(self: *OptimizedBuffer) void { @@ -324,7 +324,7 @@ pub const OptimizedBuffer = struct { pub fn pushOpacity(self: *OptimizedBuffer, opacity: f32) !void { const current = self.getCurrentOpacity(); const effective = current * std.math.clamp(opacity, 0.0, 1.0); - try self.opacity_stack.append(effective); + try self.opacity_stack.append(self.allocator, effective); } /// Pop an opacity value from the stack @@ -841,11 +841,11 @@ pub const OptimizedBuffer = struct { const is_ascii_only = utf8.isAsciiOnly(text); - var grapheme_list = std.ArrayList(utf8.GraphemeInfo).init(self.allocator); - defer grapheme_list.deinit(); + var grapheme_list: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer grapheme_list.deinit(self.allocator); const tab_width: u8 = 2; - try utf8.findGraphemeInfo(text, tab_width, is_ascii_only, self.width_method, &grapheme_list); + try utf8.findGraphemeInfo(text, tab_width, is_ascii_only, self.width_method, self.allocator, &grapheme_list); const specials = grapheme_list.items; var advance_cells: u32 = 0; diff --git a/packages/core/src/zig/build.zig b/packages/core/src/zig/build.zig index 68ad4b839..054db3b1c 100644 --- a/packages/core/src/zig/build.zig +++ b/packages/core/src/zig/build.zig @@ -8,11 +8,28 @@ const SupportedZigVersion = struct { }; const SUPPORTED_ZIG_VERSIONS = [_]SupportedZigVersion{ - .{ .major = 0, .minor = 14, .patch = 0 }, - .{ .major = 0, .minor = 14, .patch = 1 }, - // .{ .major = 0, .minor = 15, .patch = 0 }, + .{ .major = 0, .minor = 15, .patch = 2 }, }; +const SupportedTarget = struct { + zig_target: []const u8, + output_name: []const u8, + description: []const u8, +}; + +// Note: Linux targets use -gnu suffix to avoid PIC errors with ghostty's C++ deps (simdutf, highway) +const SUPPORTED_TARGETS = [_]SupportedTarget{ + .{ .zig_target = "x86_64-linux-gnu", .output_name = "x86_64-linux", .description = "Linux x86_64" }, + .{ .zig_target = "aarch64-linux-gnu", .output_name = "aarch64-linux", .description = "Linux aarch64" }, + .{ .zig_target = "x86_64-macos", .output_name = "x86_64-macos", .description = "macOS x86_64 (Intel)" }, + .{ .zig_target = "aarch64-macos", .output_name = "aarch64-macos", .description = "macOS aarch64 (Apple Silicon)" }, + .{ .zig_target = "x86_64-windows-gnu", .output_name = "x86_64-windows", .description = "Windows x86_64" }, + .{ .zig_target = "aarch64-windows-gnu", .output_name = "aarch64-windows", .description = "Windows aarch64" }, +}; + +const LIB_NAME = "opentui"; +const ROOT_SOURCE_FILE = "lib.zig"; + /// Apply dependencies to a module fn applyDependencies(b: *std.Build, module: *std.Build.Module, optimize: std.builtin.OptimizeMode, target: std.Build.ResolvedTarget) void { // Add uucode for grapheme break detection @@ -25,25 +42,12 @@ fn applyDependencies(b: *std.Build, module: *std.Build.Module, optimize: std.bui })) |uucode_dep| { module.addImport("uucode", uucode_dep.module("uucode")); } -} - -const SupportedTarget = struct { - cpu_arch: std.Target.Cpu.Arch, - os_tag: std.Target.Os.Tag, - description: []const u8, -}; -const SUPPORTED_TARGETS = [_]SupportedTarget{ - .{ .cpu_arch = .x86_64, .os_tag = .linux, .description = "Linux x86_64" }, - .{ .cpu_arch = .x86_64, .os_tag = .macos, .description = "macOS x86_64 (Intel)" }, - .{ .cpu_arch = .aarch64, .os_tag = .macos, .description = "macOS aarch64 (Apple Silicon)" }, - .{ .cpu_arch = .x86_64, .os_tag = .windows, .description = "Windows x86_64" }, - .{ .cpu_arch = .aarch64, .os_tag = .windows, .description = "Windows aarch64" }, - .{ .cpu_arch = .aarch64, .os_tag = .linux, .description = "Linux aarch64" }, -}; - -const LIB_NAME = "opentui"; -const ROOT_SOURCE_FILE = "lib.zig"; + // Add ghostty for terminal emulation + if (b.lazyDependency("ghostty", .{ .target = target, .optimize = optimize })) |ghostty_dep| { + module.addImport("ghostty-vt", ghostty_dep.module("ghostty-vt")); + } +} fn checkZigVersion() void { const current_version = builtin.zig_version; @@ -81,145 +85,150 @@ fn checkZigVersion() void { pub fn build(b: *std.Build) void { checkZigVersion(); - const optimize = b.option(std.builtin.OptimizeMode, "optimize", "Optimization level (Debug, ReleaseFast, ReleaseSafe, ReleaseSmall)") orelse .Debug; - const target_option = b.option([]const u8, "target", "Build for specific target (e.g., 'x86_64-linux'). If not specified, builds for all supported targets."); + const optimize = b.standardOptimizeOption(.{}); + const target_option = b.option([]const u8, "target", "Build for specific target (e.g., 'x86_64-linux-gnu')."); + const build_all = b.option(bool, "all", "Build for all supported targets") orelse false; if (target_option) |target_str| { + // Build single target buildSingleTarget(b, target_str, optimize) catch |err| { std.debug.print("Error building target '{s}': {}\n", .{ target_str, err }); std.process.exit(1); }; - } else { + } else if (build_all) { + // Build all supported targets buildAllTargets(b, optimize); + } else { + // Build for native target only (default) + buildNativeTarget(b, optimize); } - // Add test step - const test_step = b.step("test", "Run all tests"); - const test_target_query = std.Target.Query{ - .cpu_arch = builtin.cpu.arch, - .os_tag = builtin.os.tag, - }; - const test_target = b.resolveTargetQuery(test_target_query); - - // Run tests using the test index file - const test_exe = b.addTest(.{ + // Test step (native only) + const test_step = b.step("test", "Run unit tests"); + const native_target = b.resolveTargetQuery(.{}); + const test_mod = b.createModule(.{ .root_source_file = b.path("test.zig"), - .target = test_target, - .filter = b.option([]const u8, "test-filter", "Skip tests that do not match filter"), + .target = native_target, + .optimize = .Debug, }); - - applyDependencies(b, test_exe.root_module, .Debug, test_target); - - const run_test = b.addRunArtifact(test_exe); + applyDependencies(b, test_mod, .Debug, native_target); + const run_test = b.addRunArtifact(b.addTest(.{ + .root_module = test_mod, + .filters = if (b.option([]const u8, "test-filter", "Skip tests that do not match filter")) |f| &.{f} else &.{}, + })); test_step.dependOn(&run_test.step); - // Add bench step + // Bench step (native only) const bench_step = b.step("bench", "Run benchmarks"); - const bench_target_query = std.Target.Query{ - .cpu_arch = builtin.cpu.arch, - .os_tag = builtin.os.tag, - }; - const bench_target = b.resolveTargetQuery(bench_target_query); - - const bench_exe = b.addExecutable(.{ - .name = "opentui-bench", + const bench_mod = b.createModule(.{ .root_source_file = b.path("bench.zig"), - .target = bench_target, + .target = native_target, .optimize = optimize, }); - - applyDependencies(b, bench_exe.root_module, optimize, bench_target); - + applyDependencies(b, bench_mod, optimize, native_target); + const bench_exe = b.addExecutable(.{ + .name = "opentui-bench", + .root_module = bench_mod, + }); const run_bench = b.addRunArtifact(bench_exe); if (b.args) |args| { run_bench.addArgs(args); } bench_step.dependOn(&run_bench.step); - // Add debug step for standalone debugging + // Debug step (native only) const debug_step = b.step("debug", "Run debug executable"); - const debug_exe = b.addExecutable(.{ - .name = "opentui-debug", + const debug_mod = b.createModule(.{ .root_source_file = b.path("debug-view.zig"), - .target = test_target, + .target = native_target, .optimize = .Debug, }); - - applyDependencies(b, debug_exe.root_module, .Debug, test_target); - + applyDependencies(b, debug_mod, .Debug, native_target); + const debug_exe = b.addExecutable(.{ + .name = "opentui-debug", + .root_module = debug_mod, + }); const run_debug = b.addRunArtifact(debug_exe); debug_step.dependOn(&run_debug.step); } fn buildAllTargets(b: *std.Build, optimize: std.builtin.OptimizeMode) void { for (SUPPORTED_TARGETS) |supported_target| { - const target_query = std.Target.Query{ - .cpu_arch = supported_target.cpu_arch, - .os_tag = supported_target.os_tag, - }; - - buildTargetFromQuery(b, target_query, supported_target.description, optimize) catch |err| { + buildTarget(b, supported_target.zig_target, supported_target.output_name, supported_target.description, optimize) catch |err| { std.debug.print("Failed to build target {s}: {}\n", .{ supported_target.description, err }); continue; }; } } +fn buildNativeTarget(b: *std.Build, optimize: std.builtin.OptimizeMode) void { + // Find the matching supported target for the native platform + const native_arch = @tagName(builtin.cpu.arch); + const native_os = @tagName(builtin.os.tag); + + for (SUPPORTED_TARGETS) |supported_target| { + // Check if this target matches the native platform + if (std.mem.indexOf(u8, supported_target.zig_target, native_arch) != null and + std.mem.indexOf(u8, supported_target.zig_target, native_os) != null) + { + buildTarget(b, supported_target.zig_target, supported_target.output_name, supported_target.description, optimize) catch |err| { + std.debug.print("Failed to build native target {s}: {}\n", .{ supported_target.description, err }); + }; + return; + } + } + + std.debug.print("No matching supported target for native platform ({s}-{s})\n", .{ native_arch, native_os }); +} + fn buildSingleTarget(b: *std.Build, target_str: []const u8, optimize: std.builtin.OptimizeMode) !void { - const target_query = try std.Target.Query.parse(.{ .arch_os_abi = target_str }); + // Check if it matches a known target, use its output_name + for (SUPPORTED_TARGETS) |supported_target| { + if (std.mem.eql(u8, target_str, supported_target.zig_target)) { + try buildTarget(b, supported_target.zig_target, supported_target.output_name, supported_target.description, optimize); + return; + } + } + // Custom target - use target string as output name const description = try std.fmt.allocPrint(b.allocator, "Custom target: {s}", .{target_str}); - try buildTargetFromQuery(b, target_query, description, optimize); + try buildTarget(b, target_str, target_str, description, optimize); } -fn buildTargetFromQuery( +fn buildTarget( b: *std.Build, - target_query: std.Target.Query, + zig_target: []const u8, + output_name: []const u8, description: []const u8, optimize: std.builtin.OptimizeMode, ) !void { + const target_query = try std.Target.Query.parse(.{ .arch_os_abi = zig_target }); const target = b.resolveTargetQuery(target_query); - var target_output: *std.Build.Step.Compile = undefined; - const module = b.addModule(LIB_NAME, .{ + const module = b.createModule(.{ .root_source_file = b.path(ROOT_SOURCE_FILE), .target = target, .optimize = optimize, - .link_libc = false, }); applyDependencies(b, module, optimize, target); - target_output = b.addLibrary(.{ + const lib = b.addLibrary(.{ .name = LIB_NAME, .root_module = module, .linkage = .dynamic, }); - const target_name = try createTargetName(b.allocator, target.result); - defer b.allocator.free(target_name); - - const install_dir = b.addInstallArtifact(target_output, .{ + const install_dir = b.addInstallArtifact(lib, .{ .dest_dir = .{ .override = .{ - .custom = try std.fmt.allocPrint(b.allocator, "../lib/{s}", .{target_name}), + .custom = try std.fmt.allocPrint(b.allocator, "../lib/{s}", .{output_name}), }, }, }); - const build_step_name = try std.fmt.allocPrint(b.allocator, "build-{s}", .{target_name}); + const build_step_name = try std.fmt.allocPrint(b.allocator, "build-{s}", .{output_name}); const build_step = b.step(build_step_name, try std.fmt.allocPrint(b.allocator, "Build for {s}", .{description})); build_step.dependOn(&install_dir.step); b.getInstallStep().dependOn(&install_dir.step); } - -fn createTargetName(allocator: std.mem.Allocator, target: std.Target) ![]u8 { - return std.fmt.allocPrint( - allocator, - "{s}-{s}", - .{ - @tagName(target.cpu.arch), - @tagName(target.os.tag), - }, - ); -} diff --git a/packages/core/src/zig/build.zig.zon b/packages/core/src/zig/build.zig.zon index 0f8b4bc91..061e6ae3d 100644 --- a/packages/core/src/zig/build.zig.zon +++ b/packages/core/src/zig/build.zig.zon @@ -2,11 +2,15 @@ .name = .opentui, .version = "0.1.11", .fingerprint = 0x5445027d063f5083, - .minimum_zig_version = "0.14.1", + .minimum_zig_version = "0.15.2", .dependencies = .{ .uucode = .{ - .url = "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.1.0-zig-0.14.tar.gz", - .hash = "uucode-0.1.0-ZZjBPpAFQABNCvd9cVPBg4I7233Ays-NWfWphPNqGbyE", + .url = "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.1.0.tar.gz", + .hash = "uucode-0.1.0-ZZjBPvoGQACkgWDKIrtI8CQcSXIufU3Kvty-pIfh02i2", + }, + .ghostty = .{ + .url = "git+https://github.com/ghostty-org/ghostty.git#fbed63b0474ce0fff66859c1563a0359589ef179", + .hash = "ghostty-1.3.0-dev-5UdBC_dRRAS-mGtQa1JGeQPgcubWBeell0hGr_47fW75", }, }, .paths = .{ diff --git a/packages/core/src/zig/edit-buffer.zig b/packages/core/src/zig/edit-buffer.zig index 276ad0699..e016cfb35 100644 --- a/packages/core/src/zig/edit-buffer.zig +++ b/packages/core/src/zig/edit-buffer.zig @@ -270,7 +270,7 @@ pub const EditBuffer = struct { const base_start = chunk_ref.start; var result = try self.tb.textToSegments(self.allocator, bytes, base_mem_id, base_start, false); - defer result.segments.deinit(); + defer result.segments.deinit(result.allocator); const inserted_width = result.total_width; diff --git a/packages/core/src/zig/event-bus.zig b/packages/core/src/zig/event-bus.zig index 873c8dcc4..f1effbd34 100644 --- a/packages/core/src/zig/event-bus.zig +++ b/packages/core/src/zig/event-bus.zig @@ -1,8 +1,8 @@ const std = @import("std"); -var global_event_callback: ?*const fn (namePtr: [*]const u8, nameLen: usize, dataPtr: [*]const u8, dataLen: usize) callconv(.C) void = null; +var global_event_callback: ?*const fn (namePtr: [*]const u8, nameLen: usize, dataPtr: [*]const u8, dataLen: usize) callconv(.c) void = null; -pub fn setEventCallback(callback: ?*const fn (namePtr: [*]const u8, nameLen: usize, dataPtr: [*]const u8, dataLen: usize) callconv(.C) void) void { +pub fn setEventCallback(callback: ?*const fn (namePtr: [*]const u8, nameLen: usize, dataPtr: [*]const u8, dataLen: usize) callconv(.c) void) void { global_event_callback = callback; } diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index 8e25d2265..a2ddc0dde 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -1,6 +1,34 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +// Suppress ghostty-vt logs. Zig's std.log calls `@import("root").std_options.logFn`, +// so defining this in the root file (lib.zig) overrides logging for all modules. +pub const std_options: std.Options = .{ + .logFn = struct { + pub fn logFn( + comptime level: std.log.Level, + comptime scope: @Type(.enum_literal), + comptime format: []const u8, + args: anytype, + ) void { + // Suppress ghostty-vt related scopes + const scope_name = @tagName(scope); + const suppressed = std.mem.eql(u8, scope_name, "osc") or + std.mem.eql(u8, scope_name, "terminal") or + std.mem.eql(u8, scope_name, "stream") or + std.mem.eql(u8, scope_name, "page") or + std.mem.eql(u8, scope_name, "sgr") or + std.mem.eql(u8, scope_name, "kitty") or + std.mem.eql(u8, scope_name, "csi") or + std.mem.eql(u8, scope_name, "modes"); + if (suppressed) return; + + // Use default logging for other scopes (opentui's own logs) + std.log.defaultLog(level, scope, format, args); + } + }.logFn, +}; + const ansi = @import("ansi.zig"); const buffer = @import("buffer.zig"); const renderer = @import("renderer.zig"); @@ -16,17 +44,19 @@ const utf8 = @import("utf8.zig"); const logger = @import("logger.zig"); const event_bus = @import("event-bus.zig"); const utils = @import("utils.zig"); +const ghostty = @import("ghostty-vt"); +const vterm = @import("vterm.zig"); pub const OptimizedBuffer = buffer.OptimizedBuffer; pub const CliRenderer = renderer.CliRenderer; pub const Terminal = terminal.Terminal; pub const RGBA = buffer.RGBA; -export fn setLogCallback(callback: ?*const fn (level: u8, msgPtr: [*]const u8, msgLen: usize) callconv(.C) void) void { +export fn setLogCallback(callback: ?*const fn (level: u8, msgPtr: [*]const u8, msgLen: usize) callconv(.c) void) void { logger.setLogCallback(callback); } -export fn setEventCallback(callback: ?*const fn (namePtr: [*]const u8, nameLen: usize, dataPtr: [*]const u8, dataLen: usize) callconv(.C) void) void { +export fn setEventCallback(callback: ?*const fn (namePtr: [*]const u8, nameLen: usize, dataPtr: [*]const u8, dataLen: usize) callconv(.c) void) void { event_bus.setEventCallback(callback); } @@ -218,9 +248,9 @@ export fn clearTerminal(rendererPtr: *renderer.CliRenderer) void { export fn setTerminalTitle(rendererPtr: *renderer.CliRenderer, titlePtr: [*]const u8, titleLen: usize) void { const title = titlePtr[0..titleLen]; - var bufferedWriter = &rendererPtr.stdoutWriter; - const writer = bufferedWriter.writer(); - rendererPtr.terminal.setTerminalTitle(writer.any(), title); + var stdoutWriter = std.fs.File.stdout().writer(&rendererPtr.stdoutBuffer); + const writer = &stdoutWriter.interface; + rendererPtr.terminal.setTerminalTitle(writer, title); } // Buffer functions @@ -1372,11 +1402,11 @@ export fn encodeUnicode( const is_ascii_only = utf8.isAsciiOnly(text); // Find grapheme info - var grapheme_list = std.ArrayList(utf8.GraphemeInfo).init(std.heap.page_allocator); - defer grapheme_list.deinit(); + var grapheme_list: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer grapheme_list.deinit(std.heap.page_allocator); const tab_width: u8 = 2; - utf8.findGraphemeInfo(text, tab_width, is_ascii_only, wMethod, &grapheme_list) catch return false; + utf8.findGraphemeInfo(text, tab_width, is_ascii_only, wMethod, std.heap.page_allocator, &grapheme_list) catch return false; const specials = grapheme_list.items; // Allocate output array @@ -1510,3 +1540,72 @@ export fn bufferDrawChar( const rgbaBg = utils.f32PtrToRGBA(bg); bufferPtr.drawChar(char, x, y, rgbaFg, rgbaBg, attributes) catch {}; } + +// ============================================================================= +// VTerm FFI Export Functions +// ============================================================================= + +// NOTE: vterm.zig has its own arena allocator, separate from globalArena. +// This is critical because globalArena is shared with text buffers, editor views, etc. +// VTerm functions use caller-provides-buffer pattern (outPtr, maxLen) like rest of codebase. +// No memory management needed - JS owns the buffer. + +export fn vtermPtyToJson( + input_ptr: [*]const u8, + input_len: usize, + cols: u16, + rows: u16, + offset: usize, + limit: usize, + out_ptr: [*]u8, + max_len: usize, +) usize { + return vterm.ptyToJson(input_ptr, input_len, cols, rows, offset, limit, out_ptr, max_len); +} + +export fn vtermPtyToText( + input_ptr: [*]const u8, + input_len: usize, + cols: u16, + rows: u16, + out_ptr: [*]u8, + max_len: usize, +) usize { + return vterm.ptyToText(input_ptr, input_len, cols, rows, out_ptr, max_len); +} + +export fn vtermCreateTerminal(id: u32, cols: u32, rows: u32) bool { + return vterm.createTerminal(id, cols, rows); +} + +export fn vtermDestroyTerminal(id: u32) void { + vterm.destroyTerminal(id); +} + +export fn vtermFeedTerminal(id: u32, data_ptr: [*]const u8, data_len: usize) bool { + return vterm.feedTerminal(id, data_ptr, data_len); +} + +export fn vtermResizeTerminal(id: u32, cols: u32, rows: u32) bool { + return vterm.resizeTerminal(id, cols, rows); +} + +export fn vtermResetTerminal(id: u32) bool { + return vterm.resetTerminal(id); +} + +export fn vtermGetTerminalJson(id: u32, offset: u32, limit: u32, out_ptr: [*]u8, max_len: usize) usize { + return vterm.getTerminalJson(id, offset, limit, out_ptr, max_len); +} + +export fn vtermGetTerminalText(id: u32, out_ptr: [*]u8, max_len: usize) usize { + return vterm.getTerminalText(id, out_ptr, max_len); +} + +export fn vtermGetTerminalCursor(id: u32, out_ptr: [*]u8, max_len: usize) usize { + return vterm.getTerminalCursor(id, out_ptr, max_len); +} + +export fn vtermIsTerminalReady(id: u32) i32 { + return vterm.isTerminalReady(id); +} diff --git a/packages/core/src/zig/logger.zig b/packages/core/src/zig/logger.zig index faaf19795..b9b72c07e 100644 --- a/packages/core/src/zig/logger.zig +++ b/packages/core/src/zig/logger.zig @@ -7,9 +7,9 @@ pub const LogLevel = enum(u8) { debug = 3, }; -var global_log_callback: ?*const fn (level: u8, msgPtr: [*]const u8, msgLen: usize) callconv(.C) void = null; +var global_log_callback: ?*const fn (level: u8, msgPtr: [*]const u8, msgLen: usize) callconv(.c) void = null; -pub fn setLogCallback(callback: ?*const fn (level: u8, msgPtr: [*]const u8, msgLen: usize) callconv(.C) void) void { +pub fn setLogCallback(callback: ?*const fn (level: u8, msgPtr: [*]const u8, msgLen: usize) callconv(.c) void) void { global_log_callback = callback; } diff --git a/packages/core/src/zig/renderer.zig b/packages/core/src/zig/renderer.zig index 436835f19..cd76f7e43 100644 --- a/packages/core/src/zig/renderer.zig +++ b/packages/core/src/zig/renderer.zig @@ -69,18 +69,18 @@ pub const CliRenderer = struct { frameCallbackTime: ?f64, }, statSamples: struct { - lastFrameTime: std.ArrayList(f64), - renderTime: std.ArrayList(f64), - overallFrameTime: std.ArrayList(f64), - bufferResetTime: std.ArrayList(f64), - stdoutWriteTime: std.ArrayList(f64), - cellsUpdated: std.ArrayList(u32), - frameCallbackTime: std.ArrayList(f64), + lastFrameTime: std.ArrayListUnmanaged(f64), + renderTime: std.ArrayListUnmanaged(f64), + overallFrameTime: std.ArrayListUnmanaged(f64), + bufferResetTime: std.ArrayListUnmanaged(f64), + stdoutWriteTime: std.ArrayListUnmanaged(f64), + cellsUpdated: std.ArrayListUnmanaged(u32), + frameCallbackTime: std.ArrayListUnmanaged(f64), }, lastRenderTime: i64, allocator: Allocator, renderThread: ?std.Thread = null, - stdoutWriter: std.io.BufferedWriter(4096, std.fs.File.Writer), + stdoutBuffer: [4096]u8, debugOverlay: struct { enabled: bool, corner: DebugOverlayCorner, @@ -130,7 +130,9 @@ pub const CliRenderer = struct { return data.len; } - pub fn writer() std.io.Writer(void, error{BufferFull}, write) { + // TODO: std.io.GenericWriter is deprecated, however the "correct" option seems to be much more involved + // So I have simply used GenericWriter here, and then the proper migration can be done later + pub fn writer() std.io.GenericWriter(void, error{BufferFull}, write) { return .{ .context = {} }; } }; @@ -141,35 +143,22 @@ pub const CliRenderer = struct { const currentBuffer = try OptimizedBuffer.init(allocator, width, height, .{ .pool = pool, .width_method = .unicode, .id = "current buffer" }); const nextBuffer = try OptimizedBuffer.init(allocator, width, height, .{ .pool = pool, .width_method = .unicode, .id = "next buffer" }); - const stdoutWriter = if (testing) blk: { - // In testing mode, use /dev/null to discard output - const devnull = std.fs.openFileAbsolute("/dev/null", .{ .mode = .write_only }) catch { - // Fallback to stdout if /dev/null can't be opened - logger.warn("Failed to open /dev/null, falling back to stdout\n", .{}); - break :blk std.io.BufferedWriter(4096, std.fs.File.Writer){ .unbuffered_writer = std.io.getStdOut().writer() }; - }; - break :blk std.io.BufferedWriter(4096, std.fs.File.Writer){ .unbuffered_writer = devnull.writer() }; - } else blk: { - const stdout = std.io.getStdOut(); - break :blk std.io.BufferedWriter(4096, std.fs.File.Writer){ .unbuffered_writer = stdout.writer() }; - }; - // stat sample arrays - var lastFrameTime = std.ArrayList(f64).init(allocator); - var renderTime = std.ArrayList(f64).init(allocator); - var overallFrameTime = std.ArrayList(f64).init(allocator); - var bufferResetTime = std.ArrayList(f64).init(allocator); - var stdoutWriteTime = std.ArrayList(f64).init(allocator); - var cellsUpdated = std.ArrayList(u32).init(allocator); - var frameCallbackTimes = std.ArrayList(f64).init(allocator); - - try lastFrameTime.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); - try renderTime.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); - try overallFrameTime.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); - try bufferResetTime.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); - try stdoutWriteTime.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); - try cellsUpdated.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); - try frameCallbackTimes.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); + var lastFrameTime: std.ArrayListUnmanaged(f64) = .{}; + var renderTime: std.ArrayListUnmanaged(f64) = .{}; + var overallFrameTime: std.ArrayListUnmanaged(f64) = .{}; + var bufferResetTime: std.ArrayListUnmanaged(f64) = .{}; + var stdoutWriteTime: std.ArrayListUnmanaged(f64) = .{}; + var cellsUpdated: std.ArrayListUnmanaged(u32) = .{}; + var frameCallbackTimes: std.ArrayListUnmanaged(f64) = .{}; + + try lastFrameTime.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); + try renderTime.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); + try overallFrameTime.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); + try bufferResetTime.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); + try stdoutWriteTime.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); + try cellsUpdated.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); + try frameCallbackTimes.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); const hitGridSize = width * height; const currentHitGrid = try allocator.alloc(u32, hitGridSize); @@ -217,7 +206,7 @@ pub const CliRenderer = struct { }, .lastRenderTime = std.time.microTimestamp(), .allocator = allocator, - .stdoutWriter = stdoutWriter, + .stdoutBuffer = undefined, .currentHitGrid = currentHitGrid, .nextHitGrid = nextHitGrid, .hitGridWidth = width, @@ -251,13 +240,13 @@ pub const CliRenderer = struct { self.nextRenderBuffer.deinit(); // Free stat sample arrays - self.statSamples.lastFrameTime.deinit(); - self.statSamples.renderTime.deinit(); - self.statSamples.overallFrameTime.deinit(); - self.statSamples.bufferResetTime.deinit(); - self.statSamples.stdoutWriteTime.deinit(); - self.statSamples.cellsUpdated.deinit(); - self.statSamples.frameCallbackTime.deinit(); + self.statSamples.lastFrameTime.deinit(self.allocator); + self.statSamples.renderTime.deinit(self.allocator); + self.statSamples.overallFrameTime.deinit(self.allocator); + self.statSamples.bufferResetTime.deinit(self.allocator); + self.statSamples.stdoutWriteTime.deinit(self.allocator); + self.statSamples.cellsUpdated.deinit(self.allocator); + self.statSamples.frameCallbackTime.deinit(self.allocator); self.allocator.free(self.currentHitGrid); self.allocator.free(self.nextHitGrid); @@ -269,8 +258,8 @@ pub const CliRenderer = struct { self.useAlternateScreen = useAlternateScreen; self.terminalSetup = true; - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; self.terminal.queryTerminalSend(writer) catch { logger.warn("Failed to query terminal capabilities", .{}); @@ -280,8 +269,8 @@ pub const CliRenderer = struct { } fn setupTerminalWithoutDetection(self: *CliRenderer, useAlternateScreen: bool) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; writer.writeAll(ansi.ANSI.saveCursorState) catch {}; @@ -295,7 +284,7 @@ pub const CliRenderer = struct { const useKitty = self.terminal.opts.kitty_keyboard_flags > 0; self.terminal.enableDetectedFeatures(writer, useKitty) catch {}; - bufferedWriter.flush() catch {}; + writer.flush() catch {}; } pub fn suspendRenderer(self: *CliRenderer) void { @@ -311,16 +300,17 @@ pub const CliRenderer = struct { pub fn performShutdownSequence(self: *CliRenderer) void { if (!self.terminalSetup) return; - const direct = self.stdoutWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const direct = &stdoutWriter.interface; self.terminal.resetState(direct) catch { logger.warn("Failed to reset terminal state", .{}); }; if (self.useAlternateScreen) { - self.stdoutWriter.flush() catch {}; + direct.flush() catch {}; } else if (self.renderOffset == 0) { direct.writeAll("\x1b[H\x1b[J") catch {}; - self.stdoutWriter.flush() catch {}; + direct.flush() catch {}; } else if (self.renderOffset > 0) { // Currently still handled in typescript // const consoleEndLine = self.height - self.renderOffset; @@ -335,22 +325,22 @@ pub const CliRenderer = struct { direct.writeAll(ansi.ANSI.defaultCursorStyle) catch {}; // Workaround for Ghostty not showing the cursor after shutdown for some reason direct.writeAll(ansi.ANSI.showCursor) catch {}; - self.stdoutWriter.flush() catch {}; - std.time.sleep(10 * std.time.ns_per_ms); + direct.flush() catch {}; + std.Thread.sleep(10 * std.time.ns_per_ms); direct.writeAll(ansi.ANSI.showCursor) catch {}; - self.stdoutWriter.flush() catch {}; - std.time.sleep(10 * std.time.ns_per_ms); + direct.flush() catch {}; + std.Thread.sleep(10 * std.time.ns_per_ms); } - fn addStatSample(comptime T: type, samples: *std.ArrayList(T), value: T) void { - samples.append(value) catch return; + fn addStatSample(self: *CliRenderer, comptime T: type, samples: *std.ArrayListUnmanaged(T), value: T) void { + samples.append(self.allocator, value) catch return; if (samples.items.len > MAX_STAT_SAMPLES) { _ = samples.orderedRemove(0); } } - fn getStatAverage(comptime T: type, samples: *const std.ArrayList(T)) T { + fn getStatAverage(comptime T: type, samples: *const std.ArrayListUnmanaged(T)) T { if (samples.items.len == 0) { return 0; } @@ -406,8 +396,8 @@ pub const CliRenderer = struct { self.renderStats.fps = fps; self.renderStats.frameCallbackTime = frameCallbackTime; - addStatSample(f64, &self.statSamples.overallFrameTime, time); - addStatSample(f64, &self.statSamples.frameCallbackTime, frameCallbackTime); + self.addStatSample(f64, &self.statSamples.overallFrameTime, time); + self.addStatSample(f64, &self.statSamples.frameCallbackTime, frameCallbackTime); } pub fn updateMemoryStats(self: *CliRenderer, heapUsed: u32, heapTotal: u32, arrayBuffers: u32) void { @@ -474,10 +464,12 @@ pub const CliRenderer = struct { const outputLen = self.currentOutputLen; const writeStart = std.time.microTimestamp(); - if (outputLen > 0) { - var bufferedWriter = &self.stdoutWriter; - bufferedWriter.writer().writeAll(outputData[0..outputLen]) catch {}; - bufferedWriter.flush() catch {}; + // Skip stdout writes in testing mode to avoid blocking + if (outputLen > 0 and !self.testing) { + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const w = &stdoutWriter.interface; + w.writeAll(outputData[0..outputLen]) catch {}; + w.flush() catch {}; } // Signal that rendering is complete @@ -521,26 +513,30 @@ pub const CliRenderer = struct { self.renderMutex.unlock(); } else { const writeStart = std.time.microTimestamp(); - var bufferedWriter = &self.stdoutWriter; - bufferedWriter.writer().writeAll(outputBuffer[0..outputBufferLen]) catch {}; - bufferedWriter.flush() catch {}; + // Skip stdout writes in testing mode to avoid blocking + if (!self.testing) { + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const w = &stdoutWriter.interface; + w.writeAll(outputBuffer[0..outputBufferLen]) catch {}; + w.flush() catch {}; + } self.renderStats.stdoutWriteTime = @as(f64, @floatFromInt(std.time.microTimestamp() - writeStart)); } self.renderStats.lastFrameTime = deltaTime * 1000.0; self.renderStats.frameCount += 1; - addStatSample(f64, &self.statSamples.lastFrameTime, deltaTime * 1000.0); + self.addStatSample(f64, &self.statSamples.lastFrameTime, deltaTime * 1000.0); if (self.renderStats.renderTime) |rt| { - addStatSample(f64, &self.statSamples.renderTime, rt); + self.addStatSample(f64, &self.statSamples.renderTime, rt); } if (self.renderStats.bufferResetTime) |brt| { - addStatSample(f64, &self.statSamples.bufferResetTime, brt); + self.addStatSample(f64, &self.statSamples.bufferResetTime, brt); } if (self.renderStats.stdoutWriteTime) |swt| { - addStatSample(f64, &self.statSamples.stdoutWriteTime, swt); + self.addStatSample(f64, &self.statSamples.stdoutWriteTime, swt); } - addStatSample(u32, &self.statSamples.cellsUpdated, self.renderStats.cellsUpdated); + self.addStatSample(u32, &self.statSamples.cellsUpdated, self.renderStats.cellsUpdated); } pub fn getNextBuffer(self: *CliRenderer) *OptimizedBuffer { @@ -790,9 +786,10 @@ pub const CliRenderer = struct { } pub fn clearTerminal(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - bufferedWriter.writer().writeAll(ansi.ANSI.clearAndHome) catch {}; - bufferedWriter.flush() catch {}; + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const w = &stdoutWriter.interface; + w.writeAll(ansi.ANSI.clearAndHome) catch {}; + w.flush() catch {}; } pub fn addToHitGrid(self: *CliRenderer, x: i32, y: i32, width: u32, height: u32, id: u32) void { @@ -834,7 +831,9 @@ pub const CliRenderer = struct { const file = std.fs.cwd().createFile(filename, .{}) catch return; defer file.close(); - const writer = file.writer(); + var fileBuffer: [4096]u8 = undefined; + var fileWriter = file.writer(&fileBuffer); + const writer = &fileWriter.interface; for (0..self.hitGridHeight) |y| { for (0..self.hitGridWidth) |x| { @@ -846,6 +845,7 @@ pub const CliRenderer = struct { } writer.writeByte('\n') catch return; } + writer.flush() catch {}; } fn dumpSingleBuffer(self: *CliRenderer, buffer: *OptimizedBuffer, buffer_name: []const u8, timestamp: i64) void { @@ -860,7 +860,9 @@ pub const CliRenderer = struct { const file = std.fs.cwd().createFile(filename, .{}) catch return; defer file.close(); - const writer = file.writer(); + var fileBuffer: [4096]u8 = undefined; + var fileWriter = file.writer(&fileBuffer); + const writer = &fileWriter.interface; writer.print("{s} Buffer ({d}x{d}):\n", .{ buffer_name, self.width, self.height }) catch return; writer.writeAll("Characters:\n") catch return; @@ -886,6 +888,7 @@ pub const CliRenderer = struct { } writer.writeByte('\n') catch return; } + writer.flush() catch {}; } pub fn getLastOutputForTest(_: *CliRenderer) []const u8 { @@ -909,7 +912,9 @@ pub const CliRenderer = struct { const file = std.fs.cwd().createFile(filename, .{}) catch return; defer file.close(); - const writer = file.writer(); + var fileBuffer: [4096]u8 = undefined; + var fileWriter = file.writer(&fileBuffer); + const writer = &fileWriter.interface; writer.print("Stdout Buffer Output (timestamp: {d}):\n", .{timestamp}) catch return; writer.writeAll("Last Rendered ANSI Output:\n") catch return; @@ -927,6 +932,7 @@ pub const CliRenderer = struct { writer.writeAll("\n================\n") catch return; writer.print("Buffer size: {d} bytes\n", .{lastLen}) catch return; writer.print("Active buffer: {s}\n", .{if (activeBuffer == .A) "A" else "B"}) catch return; + writer.flush() catch {}; } pub fn dumpBuffers(self: *CliRenderer, timestamp: i64) void { @@ -937,46 +943,46 @@ pub const CliRenderer = struct { pub fn enableMouse(self: *CliRenderer, enableMovement: bool) void { _ = enableMovement; // TODO: Use this to control motion tracking levels - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; self.terminal.setMouseMode(writer, true) catch {}; - bufferedWriter.flush() catch {}; + writer.flush() catch {}; } pub fn queryPixelResolution(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; writer.writeAll(ansi.ANSI.queryPixelSize) catch {}; - bufferedWriter.flush() catch {}; + writer.flush() catch {}; } pub fn disableMouse(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; self.terminal.setMouseMode(writer, false) catch {}; - bufferedWriter.flush() catch {}; + writer.flush() catch {}; } pub fn enableKittyKeyboard(self: *CliRenderer, flags: u8) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; self.terminal.setKittyKeyboard(writer, true, flags) catch {}; - bufferedWriter.flush() catch {}; + writer.flush() catch {}; } pub fn disableKittyKeyboard(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; self.terminal.setKittyKeyboard(writer, false, 0) catch {}; - bufferedWriter.flush() catch {}; + writer.flush() catch {}; } pub fn getTerminalCapabilities(self: *CliRenderer) Terminal.Capabilities { @@ -985,7 +991,8 @@ pub const CliRenderer = struct { pub fn processCapabilityResponse(self: *CliRenderer, response: []const u8) void { self.terminal.processCapabilityResponse(response); - const writer = self.stdoutWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; const useKitty = self.terminal.opts.kitty_keyboard_flags > 0; self.terminal.enableDetectedFeatures(writer, useKitty) catch {}; } diff --git a/packages/core/src/zig/rope.zig b/packages/core/src/zig/rope.zig index 5b2ed891b..8e3945087 100644 --- a/packages/core/src/zig/rope.zig +++ b/packages/core/src/zig/rope.zig @@ -38,13 +38,13 @@ pub fn Rope(comptime T: type) type { }; pub const MarkerCache = if (marker_enabled) struct { // Flat arrays of positions for each marker type - positions: std.AutoHashMap(std.meta.Tag(T), std.ArrayList(MarkerPosition)), + positions: std.AutoHashMap(std.meta.Tag(T), std.ArrayListUnmanaged(MarkerPosition)), version: u64, // Rope version when cache was built allocator: Allocator, pub fn init(allocator: Allocator) MarkerCache { return .{ - .positions = std.AutoHashMap(std.meta.Tag(T), std.ArrayList(MarkerPosition)).init(allocator), + .positions = std.AutoHashMap(std.meta.Tag(T), std.ArrayListUnmanaged(MarkerPosition)).init(allocator), .version = std.math.maxInt(u64), // Sentinel: cache is invalid until first rebuild .allocator = allocator, }; @@ -53,7 +53,7 @@ pub fn Rope(comptime T: type) type { pub fn deinit(self: *MarkerCache) void { var iter = self.positions.valueIterator(); while (iter.next()) |list| { - list.deinit(); + list.deinit(self.allocator); } self.positions.deinit(); } @@ -291,13 +291,13 @@ pub fn Rope(comptime T: type) type { }; } - fn collect(self: *const Node, list: *std.ArrayList(*const Node)) !void { + fn collect(self: *const Node, list: *std.ArrayListUnmanaged(*const Node), allocator: Allocator) !void { switch (self.*) { .branch => |*b| { - try b.left.collect(list); - try b.right.collect(list); + try b.left.collect(list, allocator); + try b.right.collect(list, allocator); }, - .leaf => try list.append(self), + .leaf => try list.append(allocator, self), } } @@ -318,11 +318,11 @@ pub fn Rope(comptime T: type) type { pub fn rebalance(self: *const Node, allocator: Allocator, tmp_allocator: Allocator) !*const Node { if (self.is_balanced()) return self; - var leaves = std.ArrayList(*const Node).init(tmp_allocator); - defer leaves.deinit(); + var leaves: std.ArrayListUnmanaged(*const Node) = .{}; + defer leaves.deinit(tmp_allocator); - try leaves.ensureTotalCapacity(self.count()); - try self.collect(&leaves); + try leaves.ensureTotalCapacity(tmp_allocator, self.count()); + try self.collect(&leaves, tmp_allocator); return try merge_leaves(leaves.items, allocator); } @@ -524,12 +524,13 @@ pub fn Rope(comptime T: type) type { return try initWithConfig(allocator, config); } - var leaves = try std.ArrayList(*const Node).initCapacity(allocator, items.len); - defer leaves.deinit(); + var leaves: std.ArrayListUnmanaged(*const Node) = .{}; + defer leaves.deinit(allocator); + try leaves.ensureTotalCapacity(allocator, items.len); for (items) |item| { const leaf = try Node.new_leaf(allocator, item); - try leaves.append(leaf); + try leaves.append(allocator, leaf); } const root = try Node.merge_leaves(leaves.items, allocator); @@ -669,7 +670,8 @@ pub fn Rope(comptime T: type) type { if (start >= end) return &[_]T{}; const SliceContext = struct { - items: std.ArrayList(T), + items: std.ArrayListUnmanaged(T), + allocator: Allocator, start: u32, end: u32, current_index: u32 = 0, @@ -678,7 +680,7 @@ pub fn Rope(comptime T: type) type { _ = idx; const context = @as(*@This(), @ptrCast(@alignCast(ctx))); if (context.current_index >= context.start and context.current_index < context.end) { - context.items.append(data.*) catch |e| return .{ .err = e }; + context.items.append(context.allocator, data.*) catch |e| return .{ .err = e }; } context.current_index += 1; if (context.current_index >= context.end) { @@ -689,14 +691,15 @@ pub fn Rope(comptime T: type) type { }; var context = SliceContext{ - .items = std.ArrayList(T).init(allocator), + .items = .{}, + .allocator = allocator, .start = start, .end = end, }; - errdefer context.items.deinit(); + errdefer context.items.deinit(allocator); try self.walk(&context, SliceContext.walker); - return context.items.toOwnedSlice(); + return context.items.toOwnedSlice(allocator); } pub fn delete_range(self: *Self, start: u32, end: u32) !void { @@ -727,47 +730,49 @@ pub fn Rope(comptime T: type) type { pub fn to_array(self: *const Self, allocator: Allocator) ![]T { const ToArrayContext = struct { - items: std.ArrayList(T), + items: std.ArrayListUnmanaged(T), + allocator: Allocator, fn walker(ctx: *anyopaque, data: *const T, idx: u32) Node.WalkerResult { _ = idx; const context = @as(*@This(), @ptrCast(@alignCast(ctx))); - context.items.append(data.*) catch |e| return .{ .err = e }; + context.items.append(context.allocator, data.*) catch |e| return .{ .err = e }; return .{}; } }; var context = ToArrayContext{ - .items = std.ArrayList(T).init(allocator), + .items = .{}, + .allocator = allocator, }; - errdefer context.items.deinit(); + errdefer context.items.deinit(allocator); try self.walk(&context, ToArrayContext.walker); - return context.items.toOwnedSlice(); + return context.items.toOwnedSlice(allocator); } pub fn toText(self: *const Self, allocator: Allocator) ![]u8 { - var buffer = std.ArrayList(u8).init(allocator); - errdefer buffer.deinit(); + var buffer: std.ArrayListUnmanaged(u8) = .{}; + errdefer buffer.deinit(allocator); - try buffer.appendSlice("[root"); - try nodeToText(self.root, &buffer); - try buffer.append(']'); + try buffer.appendSlice(allocator, "[root"); + try nodeToText(self.root, &buffer, allocator); + try buffer.append(allocator, ']'); - return buffer.toOwnedSlice(); + return buffer.toOwnedSlice(allocator); } - fn nodeToText(node: *const Node, buffer: *std.ArrayList(u8)) !void { + fn nodeToText(node: *const Node, buffer: *std.ArrayListUnmanaged(u8), allocator: Allocator) !void { switch (node.*) { .branch => |*b| { - try buffer.appendSlice("[branch"); - try nodeToText(b.left, buffer); - try nodeToText(b.right, buffer); - try buffer.append(']'); + try buffer.appendSlice(allocator, "[branch"); + try nodeToText(b.left, buffer, allocator); + try nodeToText(b.right, buffer, allocator); + try buffer.append(allocator, ']'); }, .leaf => |*l| { if (l.is_sentinel) { - try buffer.appendSlice("[empty]"); + try buffer.appendSlice(allocator, "[empty]"); return; } @@ -775,31 +780,31 @@ pub fn Rope(comptime T: type) type { const tag = std.meta.activeTag(l.data); const tag_name = @tagName(tag); - try buffer.append('['); - try buffer.appendSlice(tag_name); + try buffer.append(allocator, '['); + try buffer.appendSlice(allocator, tag_name); if (@hasDecl(T, "Metrics")) { const metrics = l.metrics(); - try buffer.append(':'); - try std.fmt.format(buffer.writer(), "w{d}", .{metrics.weight()}); + try buffer.append(allocator, ':'); + try buffer.writer(allocator).print("w{d}", .{metrics.weight()}); if (@hasDecl(T.Metrics, "total_width")) { - try std.fmt.format(buffer.writer(), ",tw{d}", .{metrics.custom.total_width}); + try buffer.writer(allocator).print(",tw{d}", .{metrics.custom.total_width}); } if (@hasDecl(T.Metrics, "total_bytes")) { - try std.fmt.format(buffer.writer(), ",b{d}", .{metrics.custom.total_bytes}); + try buffer.writer(allocator).print(",b{d}", .{metrics.custom.total_bytes}); } } - try buffer.append(']'); + try buffer.append(allocator, ']'); } else { - try buffer.appendSlice("[leaf"); + try buffer.appendSlice(allocator, "[leaf"); if (@hasDecl(T, "Metrics")) { const metrics = l.metrics(); - try buffer.append(':'); - try std.fmt.format(buffer.writer(), "w{d}", .{metrics.weight()}); + try buffer.append(allocator, ':'); + try buffer.writer(allocator).print("w{d}", .{metrics.weight()}); } - try buffer.append(']'); + try buffer.append(allocator, ']'); } }, } @@ -925,12 +930,13 @@ pub fn Rope(comptime T: type) type { // Handle insertion if (action.insert_between.len > 0) { - var leaves = try std.ArrayList(*const Node).initCapacity(self.allocator, action.insert_between.len); - defer leaves.deinit(); + var leaves: std.ArrayListUnmanaged(*const Node) = .{}; + defer leaves.deinit(self.allocator); + try leaves.ensureTotalCapacity(self.allocator, action.insert_between.len); for (action.insert_between) |item| { const leaf = try Node.new_leaf(self.allocator, item); - try leaves.append(leaf); + try leaves.append(self.allocator, leaf); } const insert_root = try Node.merge_leaves(leaves.items, self.allocator); @@ -1115,12 +1121,13 @@ pub fn Rope(comptime T: type) type { return; } - var leaves = try std.ArrayList(*const Node).initCapacity(self.allocator, items.len); - defer leaves.deinit(); + var leaves: std.ArrayListUnmanaged(*const Node) = .{}; + defer leaves.deinit(self.allocator); + try leaves.ensureTotalCapacity(self.allocator, items.len); for (items) |item| { const leaf = try Node.new_leaf(self.allocator, item); - try leaves.append(leaf); + try leaves.append(self.allocator, leaf); } self.root = try Node.merge_leaves(leaves.items, self.allocator); @@ -1164,10 +1171,10 @@ pub fn Rope(comptime T: type) type { return .{ .keep_walking = false, .err = e }; }; if (!gop.found_existing) { - gop.value_ptr.* = std.ArrayList(MarkerPosition).init(context.cache.allocator); + gop.value_ptr.* = .{}; } - gop.value_ptr.append(.{ + gop.value_ptr.append(context.cache.allocator, .{ .leaf_index = context.current_leaf, .global_weight = context.current_weight, }) catch |e| { diff --git a/packages/core/src/zig/syntax-style.zig b/packages/core/src/zig/syntax-style.zig index 1d8434a19..198954f26 100644 --- a/packages/core/src/zig/syntax-style.zig +++ b/packages/core/src/zig/syntax-style.zig @@ -109,7 +109,7 @@ pub const SyntaxStyle = struct { for (ids, 0..) |id, i| { if (i > 0) writer.writeByte(':') catch return SyntaxStyleError.OutOfMemory; - std.fmt.formatInt(id, 10, .lower, .{}, writer) catch return SyntaxStyleError.OutOfMemory; + writer.print("{d}", .{id}) catch return SyntaxStyleError.OutOfMemory; } const cache_key = cache_key_stream.getWritten(); diff --git a/packages/core/src/zig/test.zig b/packages/core/src/zig/test.zig index fbae51eec..cf5d76d65 100644 --- a/packages/core/src/zig/test.zig +++ b/packages/core/src/zig/test.zig @@ -58,5 +58,6 @@ comptime { _ = terminal_tests; _ = mem_registry_tests; _ = memory_leak_regression_tests; + // _ = example_tests; } diff --git a/packages/core/src/zig/tests/buffer_test.zig b/packages/core/src/zig/tests/buffer_test.zig index 86b11c22c..a9a0bf7f4 100644 --- a/packages/core/src/zig/tests/buffer_test.zig +++ b/packages/core/src/zig/tests/buffer_test.zig @@ -251,14 +251,14 @@ test "OptimizedBuffer - large text buffer with wrapping repeated render" { var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); var line: u32 = 0; while (line < 20) : (line += 1) { - try text_builder.appendSlice("Line "); - try std.fmt.format(text_builder.writer(), "{d}", .{line}); - try text_builder.appendSlice(": 🌟 测试 🎨 Test 🚀\n"); + try text_builder.appendSlice(std.testing.allocator, "Line "); + try text_builder.writer(std.testing.allocator).print("{d}", .{line}); + try text_builder.appendSlice(std.testing.allocator, ": 🌟 测试 🎨 Test 🚀\n"); } try tb.setText(text_builder.items); @@ -416,12 +416,12 @@ test "OptimizedBuffer - stress test with many graphemes" { var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); var line: u32 = 0; while (line < 10) : (line += 1) { - try text_builder.appendSlice("🌟🎨🚀🍕🍔🍟🌈🎭🎪🎨🎬🎤🎧🎼🎹🎺🎸🎻\n"); + try text_builder.appendSlice(std.testing.allocator, "🌟🎨🚀🍕🍔🍟🌈🎭🎪🎨🎬🎤🎧🎼🎹🎺🎸🎻\n"); } try tb.setText(text_builder.items); @@ -515,8 +515,8 @@ test "OptimizedBuffer - many unique graphemes with small pool" { var failure_count: u32 = 0; while (render_count < 1000) : (render_count += 1) { - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); const base_codepoint: u21 = 0x2600 + @as(u21, @intCast(render_count % 500)); const char_bytes = [_]u8{ @@ -524,9 +524,9 @@ test "OptimizedBuffer - many unique graphemes with small pool" { @intCast(0x80 | ((base_codepoint >> 6) & 0x3F)), @intCast(0x80 | (base_codepoint & 0x3F)), }; - try text_builder.appendSlice(&char_bytes); - try text_builder.appendSlice(" "); - try text_builder.appendSlice(&char_bytes); + try text_builder.appendSlice(std.testing.allocator, &char_bytes); + try text_builder.appendSlice(std.testing.allocator, " "); + try text_builder.appendSlice(std.testing.allocator, &char_bytes); tb.setText(text_builder.items) catch { failure_count += 1; diff --git a/packages/core/src/zig/tests/grapheme_test.zig b/packages/core/src/zig/tests/grapheme_test.zig index 9320ade20..d9c0757ec 100644 --- a/packages/core/src/zig/tests/grapheme_test.zig +++ b/packages/core/src/zig/tests/grapheme_test.zig @@ -285,16 +285,16 @@ test "GraphemePool - many allocations" { for (0..count) |i| { var buffer: [8]u8 = undefined; - const len = std.fmt.formatIntBuf(&buffer, i, 10, .lower, .{}); - ids[i] = try pool.alloc(buffer[0..len]); + const slice = std.fmt.bufPrint(&buffer, "{d}", .{i}) catch unreachable; + ids[i] = try pool.alloc(slice); try pool.incref(ids[i]); } for (ids, 0..count) |id, i| { const retrieved = try pool.get(id); var buffer: [8]u8 = undefined; - const len = std.fmt.formatIntBuf(&buffer, i, 10, .lower, .{}); - try std.testing.expectEqualSlices(u8, buffer[0..len], retrieved); + const slice = std.fmt.bufPrint(&buffer, "{d}", .{i}) catch unreachable; + try std.testing.expectEqualSlices(u8, slice, retrieved); } for (ids) |id| { @@ -306,8 +306,8 @@ test "GraphemePool - allocations with varying sizes" { var pool = GraphemePool.init(std.testing.allocator); defer pool.deinit(); - var ids = std.ArrayList(u32).init(std.testing.allocator); - defer ids.deinit(); + var ids: std.ArrayListUnmanaged(u32) = .{}; + defer ids.deinit(std.testing.allocator); for (0..50) |i| { const size = (i % 5) * 16 + 5; // Vary sizes: 5, 21, 37, 53, 69... @@ -315,7 +315,7 @@ test "GraphemePool - allocations with varying sizes" { @memset(buffer[0..size], @intCast(i % 256)); const id = try pool.alloc(buffer[0..size]); try pool.incref(id); - try ids.append(id); + try ids.append(std.testing.allocator, id); } for (ids.items, 0..50) |id, i| { @@ -338,12 +338,12 @@ test "GraphemePool - reuse many slots" { for (0..100) |i| { var buffer: [8]u8 = undefined; - const len = std.fmt.formatIntBuf(&buffer, i, 10, .lower, .{}); - const id = try pool.alloc(buffer[0..len]); + const slice = std.fmt.bufPrint(&buffer, "{d}", .{i}) catch unreachable; + const id = try pool.alloc(slice); try pool.incref(id); const retrieved = try pool.get(id); - try std.testing.expectEqualSlices(u8, buffer[0..len], retrieved); + try std.testing.expectEqualSlices(u8, slice, retrieved); try pool.decref(id); } @@ -427,8 +427,8 @@ test "GraphemePool - IDs remain unique across many allocations" { for (0..count) |i| { var buffer: [8]u8 = undefined; - const len = std.fmt.formatIntBuf(&buffer, i, 10, .lower, .{}); - ids[i] = try pool.alloc(buffer[0..len]); + const slice = std.fmt.bufPrint(&buffer, "{d}", .{i}) catch unreachable; + ids[i] = try pool.alloc(slice); try pool.incref(ids[i]); } @@ -827,8 +827,8 @@ test "GraphemeTracker - stress test many graphemes" { // Add many graphemes for (0..count) |i| { var buffer: [8]u8 = undefined; - const len = std.fmt.formatIntBuf(&buffer, i, 10, .lower, .{}); - ids[i] = try pool.alloc(buffer[0..len]); + const slice = std.fmt.bufPrint(&buffer, "{d}", .{i}) catch unreachable; + ids[i] = try pool.alloc(slice); tracker.add(ids[i]); } diff --git a/packages/core/src/zig/tests/memory_leak_regression_test.zig b/packages/core/src/zig/tests/memory_leak_regression_test.zig index 01b23a9b1..d610909e8 100644 --- a/packages/core/src/zig/tests/memory_leak_regression_test.zig +++ b/packages/core/src/zig/tests/memory_leak_regression_test.zig @@ -23,15 +23,15 @@ test "GraphemePool - defer cleanup on failure path" { var pool = GraphemePool.init(std.testing.allocator); defer pool.deinit(); - var allocated_ids = std.ArrayList(u32).init(std.testing.allocator); - defer allocated_ids.deinit(); + var allocated_ids: std.ArrayListUnmanaged(u32) = .{}; + defer allocated_ids.deinit(std.testing.allocator); for (0..5) |i| { var buffer: [8]u8 = undefined; - const len = std.fmt.formatIntBuf(&buffer, i, 10, .lower, .{}); - const gid = try pool.alloc(buffer[0..len]); + const slice = std.fmt.bufPrint(&buffer, "{d}", .{i}) catch unreachable; + const gid = try pool.alloc(slice); try pool.incref(gid); - try allocated_ids.append(gid); + try allocated_ids.append(std.testing.allocator, gid); } // Simulate failure cleanup @@ -53,8 +53,8 @@ test "GraphemePool - pending grapheme cleanup on failure" { var pool = GraphemePool.init(std.testing.allocator); defer pool.deinit(); - var result_graphemes = std.ArrayList(u32).init(std.testing.allocator); - defer result_graphemes.deinit(); + var result_graphemes: std.ArrayListUnmanaged(u32) = .{}; + defer result_graphemes.deinit(std.testing.allocator); var pending_gid: ?u32 = null; const success = false; // intentionally never true to test cleanup path @@ -73,7 +73,7 @@ test "GraphemePool - pending grapheme cleanup on failure" { const gid1 = try pool.alloc("grapheme1"); pending_gid = gid1; try pool.incref(gid1); - try result_graphemes.append(gid1); + try result_graphemes.append(std.testing.allocator, gid1); pending_gid = null; const gid2 = try pool.alloc("grapheme2"); diff --git a/packages/core/src/zig/tests/text-buffer-drawing_test.zig b/packages/core/src/zig/tests/text-buffer-drawing_test.zig index 1453086e7..85be4276b 100644 --- a/packages/core/src/zig/tests/text-buffer-drawing_test.zig +++ b/packages/core/src/zig/tests/text-buffer-drawing_test.zig @@ -455,9 +455,9 @@ test "drawTextBuffer - very long unwrapped line clipping" { var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var long_text = std.ArrayList(u8).init(std.testing.allocator); - defer long_text.deinit(); - try long_text.appendNTimes('A', 200); + var long_text: std.ArrayListUnmanaged(u8) = .{}; + defer long_text.deinit(std.testing.allocator); + try long_text.appendNTimes(std.testing.allocator, 'A', 200); try tb.setText(long_text.items); view.setWrapMode(.word); @@ -1358,9 +1358,9 @@ test "drawTextBuffer - horizontal viewport width limits rendering (efficiency te var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var long_line = std.ArrayList(u8).init(std.testing.allocator); - defer long_line.deinit(); - try long_line.appendNTimes('A', 1000); + var long_line: std.ArrayListUnmanaged(u8) = .{}; + defer long_line.deinit(std.testing.allocator); + try long_line.appendNTimes(std.testing.allocator, 'A', 1000); try tb.setText(long_line.items); diff --git a/packages/core/src/zig/tests/text-buffer-iterators_test.zig b/packages/core/src/zig/tests/text-buffer-iterators_test.zig index c6c741226..34153827c 100644 --- a/packages/core/src/zig/tests/text-buffer-iterators_test.zig +++ b/packages/core/src/zig/tests/text-buffer-iterators_test.zig @@ -55,16 +55,17 @@ test "walkLines - single text segment" { }); const Context = struct { - lines: std.ArrayList(LineInfo), + lines: std.ArrayListUnmanaged(LineInfo), + allocator: std.mem.Allocator, fn callback(ctx_ptr: *anyopaque, line_info: LineInfo) void { const ctx = @as(*@This(), @ptrCast(@alignCast(ctx_ptr))); - ctx.lines.append(line_info) catch {}; + ctx.lines.append(ctx.allocator, line_info) catch {}; } }; - var ctx = Context{ .lines = std.ArrayList(LineInfo).init(allocator) }; - defer ctx.lines.deinit(); + var ctx = Context{ .lines = .{}, .allocator = allocator }; + defer ctx.lines.deinit(allocator); iter_mod.walkLines(&rope, &ctx, Context.callback, true); @@ -101,16 +102,17 @@ test "walkLines - text + break + text" { }); const Context = struct { - lines: std.ArrayList(LineInfo), + lines: std.ArrayListUnmanaged(LineInfo), + allocator: std.mem.Allocator, fn callback(ctx_ptr: *anyopaque, line_info: LineInfo) void { const ctx = @as(*@This(), @ptrCast(@alignCast(ctx_ptr))); - ctx.lines.append(line_info) catch {}; + ctx.lines.append(ctx.allocator, line_info) catch {}; } }; - var ctx = Context{ .lines = std.ArrayList(LineInfo).init(allocator) }; - defer ctx.lines.deinit(); + var ctx = Context{ .lines = .{}, .allocator = allocator }; + defer ctx.lines.deinit(allocator); iter_mod.walkLines(&rope, &ctx, Context.callback, true); @@ -154,16 +156,17 @@ test "walkLines - exclude newlines in offset" { }); const Context = struct { - lines: std.ArrayList(LineInfo), + lines: std.ArrayListUnmanaged(LineInfo), + allocator: std.mem.Allocator, fn callback(ctx_ptr: *anyopaque, line_info: LineInfo) void { const ctx = @as(*@This(), @ptrCast(@alignCast(ctx_ptr))); - ctx.lines.append(line_info) catch {}; + ctx.lines.append(ctx.allocator, line_info) catch {}; } }; - var ctx = Context{ .lines = std.ArrayList(LineInfo).init(allocator) }; - defer ctx.lines.deinit(); + var ctx = Context{ .lines = .{}, .allocator = allocator }; + defer ctx.lines.deinit(allocator); iter_mod.walkLines(&rope, &ctx, Context.callback, false); diff --git a/packages/core/src/zig/tests/text-buffer-view_test.zig b/packages/core/src/zig/tests/text-buffer-view_test.zig index a8b155c42..69e7b4c26 100644 --- a/packages/core/src/zig/tests/text-buffer-view_test.zig +++ b/packages/core/src/zig/tests/text-buffer-view_test.zig @@ -699,13 +699,13 @@ test "TextBufferView word wrapping - fragmented rope with word boundary" { const chunk2 = tb.createChunk(mem_id, 14, 15); // "f" const chunk3 = tb.createChunk(mem_id, 15, 20); // "riend" - var segments = std.ArrayList(Segment).init(std.testing.allocator); - defer segments.deinit(); + var segments: std.ArrayListUnmanaged(Segment) = .{}; + defer segments.deinit(std.testing.allocator); - try segments.append(Segment{ .linestart = {} }); - try segments.append(Segment{ .text = chunk1 }); - try segments.append(Segment{ .text = chunk2 }); - try segments.append(Segment{ .text = chunk3 }); + try segments.append(std.testing.allocator, Segment{ .linestart = {} }); + try segments.append(std.testing.allocator, Segment{ .text = chunk1 }); + try segments.append(std.testing.allocator, Segment{ .text = chunk2 }); + try segments.append(std.testing.allocator, Segment{ .text = chunk3 }); try tb.rope.setSegments(segments.items); @@ -1436,11 +1436,11 @@ test "TextBufferView line info - lines with different widths" { var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); - try text_builder.appendSlice("Short\n"); - try text_builder.appendNTimes('A', 50); - try text_builder.appendSlice("\nMedium"); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); + try text_builder.appendSlice(std.testing.allocator, "Short\n"); + try text_builder.appendNTimes(std.testing.allocator, 'A', 50); + try text_builder.appendSlice(std.testing.allocator, "\nMedium"); const text = text_builder.items; try tb.setText(text); @@ -1484,14 +1484,14 @@ test "TextBufferView line info - thousands of lines" { var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); var i: u32 = 0; while (i < 999) : (i += 1) { - try std.fmt.format(text_builder.writer(), "Line {}\n", .{i}); + try text_builder.writer(std.testing.allocator).print("Line {}\n", .{i}); } - try std.fmt.format(text_builder.writer(), "Line {}", .{i}); + try text_builder.writer(std.testing.allocator).print("Line {}", .{i}); try tb.setText(text_builder.items); @@ -2488,14 +2488,14 @@ test "TextBufferView line info - line starts monotonically increasing" { var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); var i: u32 = 0; while (i < 99) : (i += 1) { - try std.fmt.format(text_builder.writer(), "Line {}\n", .{i}); + try text_builder.writer(std.testing.allocator).print("Line {}\n", .{i}); } - try std.fmt.format(text_builder.writer(), "Line {}", .{i}); + try text_builder.writer(std.testing.allocator).print("Line {}", .{i}); try tb.setText(text_builder.items); @@ -2897,16 +2897,16 @@ test "TextBufferView word wrapping - chunk at exact wrap boundary" { const seg_mod = @import("../text-buffer-segment.zig"); const Segment = seg_mod.Segment; - var segments = std.ArrayList(Segment).init(std.testing.allocator); - defer segments.deinit(); + var segments: std.ArrayListUnmanaged(Segment) = .{}; + defer segments.deinit(std.testing.allocator); - try segments.append(Segment{ .linestart = {} }); + try segments.append(std.testing.allocator, Segment{ .linestart = {} }); const chunk1 = tb.createChunk(mem_id, 0, 17); - try segments.append(Segment{ .text = chunk1 }); + try segments.append(std.testing.allocator, Segment{ .text = chunk1 }); const chunk2 = tb.createChunk(mem_id, 17, 21); - try segments.append(Segment{ .text = chunk2 }); + try segments.append(std.testing.allocator, Segment{ .text = chunk2 }); try tb.rope.setSegments(segments.items); view.virtual_lines_dirty = true; diff --git a/packages/core/src/zig/tests/text-buffer_test.zig b/packages/core/src/zig/tests/text-buffer_test.zig index e5ab8b763..b774c10b1 100644 --- a/packages/core/src/zig/tests/text-buffer_test.zig +++ b/packages/core/src/zig/tests/text-buffer_test.zig @@ -238,11 +238,11 @@ test "TextBuffer line info - lines with different widths" { defer tb.deinit(); // Create text with different line lengths - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); - try text_builder.appendSlice("Short\n"); - try text_builder.appendNTimes('A', 50); - try text_builder.appendSlice("\nMedium"); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); + try text_builder.appendSlice(std.testing.allocator, "Short\n"); + try text_builder.appendNTimes(std.testing.allocator, 'A', 50); + try text_builder.appendSlice(std.testing.allocator, "\nMedium"); const text = text_builder.items; try tb.setText(text); @@ -335,11 +335,11 @@ test "TextBuffer line info - buffer resize operations" { defer tb.deinit(); // Add text that will cause multiple resizes - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); - try text_builder.appendNTimes('A', 100); - try text_builder.appendSlice("\n"); - try text_builder.appendNTimes('B', 100); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); + try text_builder.appendNTimes(std.testing.allocator, 'A', 100); + try text_builder.appendSlice(std.testing.allocator, "\n"); + try text_builder.appendNTimes(std.testing.allocator, 'B', 100); const longText = text_builder.items; try tb.setText(longText); @@ -355,15 +355,15 @@ test "TextBuffer line info - thousands of lines" { defer tb.deinit(); // Create text with 1000 lines - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); var i: u32 = 0; while (i < 999) : (i += 1) { - try std.fmt.format(text_builder.writer(), "Line {}\n", .{i}); + try text_builder.writer(std.testing.allocator).print("Line {}\n", .{i}); } // Last line without newline - try std.fmt.format(text_builder.writer(), "Line {}", .{i}); + try text_builder.writer(std.testing.allocator).print("Line {}", .{i}); try tb.setText(text_builder.items); @@ -543,16 +543,17 @@ test "TextBuffer line iteration - walkLines callback" { try tb.setText(text); const Context = struct { - lines: std.ArrayList(iter_mod.LineInfo), + lines: std.ArrayListUnmanaged(iter_mod.LineInfo), + allocator: std.mem.Allocator, fn callback(ctx_ptr: *anyopaque, line_info: iter_mod.LineInfo) void { const ctx = @as(*@This(), @ptrCast(@alignCast(ctx_ptr))); - ctx.lines.append(line_info) catch {}; + ctx.lines.append(ctx.allocator, line_info) catch {}; } }; - var ctx = Context{ .lines = std.ArrayList(iter_mod.LineInfo).init(std.testing.allocator) }; - defer ctx.lines.deinit(); + var ctx = Context{ .lines = .{}, .allocator = std.testing.allocator }; + defer ctx.lines.deinit(std.testing.allocator); iter_mod.walkLines(&tb.rope, &ctx, Context.callback, true); @@ -1380,14 +1381,14 @@ test "TextBuffer setText - very long line with SIMD processing" { defer tb.deinit(); // Create a text longer than 16 bytes (SIMD vector size) to test SIMD path - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); - try text_builder.appendNTimes('A', 100); - try text_builder.appendSlice("\r\n"); - try text_builder.appendNTimes('B', 100); - try text_builder.appendSlice("\n"); - try text_builder.appendNTimes('C', 100); + try text_builder.appendNTimes(std.testing.allocator, 'A', 100); + try text_builder.appendSlice(std.testing.allocator, "\r\n"); + try text_builder.appendNTimes(std.testing.allocator, 'B', 100); + try text_builder.appendSlice(std.testing.allocator, "\n"); + try text_builder.appendNTimes(std.testing.allocator, 'C', 100); try tb.setText(text_builder.items); @@ -1438,17 +1439,17 @@ test "TextBuffer setText - SIMD boundary conditions" { defer tb.deinit(); // Create text with newlines at SIMD vector boundaries (16 bytes) - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); // 15 chars + \n = exactly 16 bytes - try text_builder.appendNTimes('X', 15); - try text_builder.appendSlice("\n"); + try text_builder.appendNTimes(std.testing.allocator, 'X', 15); + try text_builder.appendSlice(std.testing.allocator, "\n"); // 15 more chars + \n - try text_builder.appendNTimes('Y', 15); - try text_builder.appendSlice("\n"); + try text_builder.appendNTimes(std.testing.allocator, 'Y', 15); + try text_builder.appendSlice(std.testing.allocator, "\n"); // Final line - try text_builder.appendNTimes('Z', 10); + try text_builder.appendNTimes(std.testing.allocator, 'Z', 10); try tb.setText(text_builder.items); @@ -1467,13 +1468,13 @@ test "TextBuffer setText - CRLF at SIMD boundary" { defer tb.deinit(); // Create text where \r is at end of SIMD vector and \n is at start of next - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); // 15 chars + \r = 16 bytes, then \n at position 16 - try text_builder.appendNTimes('A', 15); - try text_builder.appendSlice("\r\n"); - try text_builder.appendSlice("Next line"); + try text_builder.appendNTimes(std.testing.allocator, 'A', 15); + try text_builder.appendSlice(std.testing.allocator, "\r\n"); + try text_builder.appendSlice(std.testing.allocator, "Next line"); try tb.setText(text_builder.items); @@ -1874,13 +1875,13 @@ test "TextBuffer append - streaming/chunked append vs ground truth" { try tb.append(" end"); // Build expected ground truth - var expected = std.ArrayList(u8).init(std.testing.allocator); - defer expected.deinit(); - try expected.appendSlice("First"); - try expected.appendSlice("\nLine2"); - try expected.appendSlice("\n"); - try expected.appendSlice("Line3"); - try expected.appendSlice(" end"); + var expected: std.ArrayListUnmanaged(u8) = .{}; + defer expected.deinit(std.testing.allocator); + try expected.appendSlice(std.testing.allocator, "First"); + try expected.appendSlice(std.testing.allocator, "\nLine2"); + try expected.appendSlice(std.testing.allocator, "\n"); + try expected.appendSlice(std.testing.allocator, "Line3"); + try expected.appendSlice(std.testing.allocator, " end"); var out_buffer: [100]u8 = undefined; const written = tb.getPlainTextIntoBuffer(&out_buffer); diff --git a/packages/core/src/zig/tests/utf8_no_zwj_test.zig b/packages/core/src/zig/tests/utf8_no_zwj_test.zig index d7d2b3c8d..85d3552ee 100644 --- a/packages/core/src/zig/tests/utf8_no_zwj_test.zig +++ b/packages/core/src/zig/tests/utf8_no_zwj_test.zig @@ -91,15 +91,15 @@ test "no_zwj: mixed text with ZWJ emoji" { } test "no_zwj: findGraphemeInfo splits ZWJ sequences" { - var result_unicode = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result_unicode.deinit(); - var result_no_zwj = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result_no_zwj.deinit(); + var result_unicode: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result_unicode.deinit(testing.allocator); + var result_no_zwj: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result_no_zwj.deinit(testing.allocator); const text = "Hi👩‍🚀Bye"; - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result_unicode); - try utf8.findGraphemeInfo(text, 4, false, .no_zwj, &result_no_zwj); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result_unicode); + try utf8.findGraphemeInfo(text, 4, false, .no_zwj, testing.allocator, &result_no_zwj); // unicode: 1 grapheme (the whole ZWJ sequence) try testing.expectEqual(@as(usize, 1), result_unicode.items.len); diff --git a/packages/core/src/zig/tests/utf8_test.zig b/packages/core/src/zig/tests/utf8_test.zig index dc0e82aa7..ec5de8ab1 100644 --- a/packages/core/src/zig/tests/utf8_test.zig +++ b/packages/core/src/zig/tests/utf8_test.zig @@ -2077,26 +2077,26 @@ test "calculateTextWidth: U+269B atom symbol should be width 2" { // ============================================================================ test "findGraphemeInfo: empty string" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("", 4, true, .unicode, &result); + try utf8.findGraphemeInfo("", 4, true, .unicode, testing.allocator, &result); try testing.expectEqual(@as(usize, 0), result.items.len); } test "findGraphemeInfo: ASCII-only returns empty" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("hello world", 4, true, .unicode, &result); + try utf8.findGraphemeInfo("hello world", 4, true, .unicode, testing.allocator, &result); try testing.expectEqual(@as(usize, 0), result.items.len); } test "findGraphemeInfo: ASCII with tab" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("hello\tworld", 4, false, .unicode, &result); + try utf8.findGraphemeInfo("hello\tworld", 4, false, .unicode, testing.allocator, &result); // Should have one entry for the tab try testing.expectEqual(@as(usize, 1), result.items.len); @@ -2107,10 +2107,10 @@ test "findGraphemeInfo: ASCII with tab" { } test "findGraphemeInfo: multiple tabs" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("a\tb\tc", 4, false, .unicode, &result); + try utf8.findGraphemeInfo("a\tb\tc", 4, false, .unicode, testing.allocator, &result); // Should have two entries for the tabs try testing.expectEqual(@as(usize, 2), result.items.len); @@ -2129,11 +2129,11 @@ test "findGraphemeInfo: multiple tabs" { } test "findGraphemeInfo: CJK characters" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "hello世界"; - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have two entries for the CJK characters try testing.expectEqual(@as(usize, 2), result.items.len); @@ -2152,11 +2152,11 @@ test "findGraphemeInfo: CJK characters" { } test "findGraphemeInfo: emoji with skin tone" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "Hi👋🏿Bye"; // Hi + wave + dark skin tone + Bye - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have one entry for the emoji cluster try testing.expectEqual(@as(usize, 1), result.items.len); @@ -2168,11 +2168,11 @@ test "findGraphemeInfo: emoji with skin tone" { } test "findGraphemeInfo: emoji with ZWJ" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "a👩‍🚀b"; // a + woman astronaut + b - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have one entry for the emoji cluster try testing.expectEqual(@as(usize, 1), result.items.len); @@ -2183,11 +2183,11 @@ test "findGraphemeInfo: emoji with ZWJ" { } test "findGraphemeInfo: combining mark" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "cafe\u{0301}"; // café with combining acute - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have one entry for e + combining mark try testing.expectEqual(@as(usize, 1), result.items.len); @@ -2199,11 +2199,11 @@ test "findGraphemeInfo: combining mark" { } test "findGraphemeInfo: flag emoji" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "US🇺🇸"; // US + flag - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have one entry for the flag (two regional indicators) try testing.expectEqual(@as(usize, 1), result.items.len); @@ -2215,11 +2215,11 @@ test "findGraphemeInfo: flag emoji" { } test "findGraphemeInfo: mixed content" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "Hi\t世界!"; // Hi + tab + CJK + ! - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have three entries: tab, 世, 界 try testing.expectEqual(@as(usize, 3), result.items.len); @@ -2244,21 +2244,21 @@ test "findGraphemeInfo: mixed content" { } test "findGraphemeInfo: only ASCII letters no cache" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("abcdefghij", 4, false, .unicode, &result); + try utf8.findGraphemeInfo("abcdefghij", 4, false, .unicode, testing.allocator, &result); // No special characters, should be empty try testing.expectEqual(@as(usize, 0), result.items.len); } test "findGraphemeInfo: emoji with VS16" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "I ❤️ U"; // I + space + heart + VS16 + space + U - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have one entry for the emoji cluster try testing.expectEqual(@as(usize, 1), result.items.len); @@ -2269,22 +2269,22 @@ test "findGraphemeInfo: emoji with VS16" { } test "findGraphemeInfo: realistic text" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "function test() {\n\tconst 世界 = 10;\n}"; - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have entries for: tab, 世, 界 try testing.expectEqual(@as(usize, 3), result.items.len); } test "findGraphemeInfo: hiragana" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "こんにちは"; - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have 5 entries (each hiragana is 3 bytes, width 2) try testing.expectEqual(@as(usize, 5), result.items.len); @@ -2296,8 +2296,8 @@ test "findGraphemeInfo: hiragana" { } test "findGraphemeInfo: at SIMD boundary" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); // Create text with multibyte char near SIMD boundary (16 bytes) var buf: [32]u8 = undefined; @@ -2305,7 +2305,7 @@ test "findGraphemeInfo: at SIMD boundary" { const cjk = "世"; @memcpy(buf[14..17], cjk); // Place CJK char at boundary - try utf8.findGraphemeInfo(&buf, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(&buf, 4, false, .unicode, testing.allocator, &result); // Should find the CJK character var found = false; @@ -2919,14 +2919,14 @@ test "calculateTextWidth: surrogate pair edge cases" { test "calculateTextWidth: long grapheme cluster chain" { // Create a base + many combining marks - var text = std.ArrayList(u8).init(testing.allocator); - defer text.deinit(); + var text: std.ArrayListUnmanaged(u8) = .{}; + defer text.deinit(testing.allocator); - try text.appendSlice("e"); + try text.appendSlice(testing.allocator, "e"); // Add 10 combining marks var i: usize = 0; while (i < 10) : (i += 1) { - try text.appendSlice("\u{0301}"); // Combining acute accent + try text.appendSlice(testing.allocator, "\u{0301}"); // Combining acute accent } const width = utf8.calculateTextWidth(text.items, 4, false, .unicode); @@ -3509,32 +3509,28 @@ test "calculateTextWidth: complex text with emojis and multiple scripts" { test "calculateTextWidth: validate against unicode-width-map.zon" { const zon_content = @embedFile("unicode-width-map.zon"); - const zon_with_null = try testing.allocator.dupeZ(u8, zon_content); - defer testing.allocator.free(zon_with_null); + + // Use arena allocator to avoid memory leaks from ZON parser string allocations + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const zon_with_null = try allocator.dupeZ(u8, zon_content); const WidthEntry = struct { codepoint: []const u8, width: i32, }; - var status: std.zon.parse.Status = .{}; - defer status.deinit(testing.allocator); - const width_entries = std.zon.parse.fromSlice( []const WidthEntry, - testing.allocator, + allocator, zon_with_null, - &status, + null, .{}, ) catch |err| { return err; }; - defer { - for (width_entries) |entry| { - testing.allocator.free(entry.codepoint); - } - testing.allocator.free(width_entries); - } var successes: usize = 0; var failures: usize = 0; @@ -3592,10 +3588,10 @@ test "findGraphemeInfo: comprehensive multilingual text" { const expected_width = utf8.calculateTextWidth(text, 4, false, .unicode); - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); try testing.expect(result.items.len > 0); var prev_end_byte: usize = 0; diff --git a/packages/core/src/zig/tests/utf8_wcwidth_test.zig b/packages/core/src/zig/tests/utf8_wcwidth_test.zig index a0131d5a3..aec6338f6 100644 --- a/packages/core/src/zig/tests/utf8_wcwidth_test.zig +++ b/packages/core/src/zig/tests/utf8_wcwidth_test.zig @@ -3,26 +3,26 @@ const testing = std.testing; const utf8 = @import("../utf8.zig"); test "findGraphemeInfo wcwidth: empty string" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("", 4, true, .wcwidth, &result); + try utf8.findGraphemeInfo("", 4, true, .wcwidth, testing.allocator, &result); try testing.expectEqual(@as(usize, 0), result.items.len); } test "findGraphemeInfo wcwidth: ASCII-only returns empty" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("hello world", 4, true, .wcwidth, &result); + try utf8.findGraphemeInfo("hello world", 4, true, .wcwidth, testing.allocator, &result); try testing.expectEqual(@as(usize, 0), result.items.len); } test "findGraphemeInfo wcwidth: ASCII with tab" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("hello\tworld", 4, false, .wcwidth, &result); + try utf8.findGraphemeInfo("hello\tworld", 4, false, .wcwidth, testing.allocator, &result); // Should have one entry for the tab try testing.expectEqual(@as(usize, 1), result.items.len); @@ -33,11 +33,11 @@ test "findGraphemeInfo wcwidth: ASCII with tab" { } test "findGraphemeInfo wcwidth: CJK characters" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "hello世界"; - try utf8.findGraphemeInfo(text, 4, false, .wcwidth, &result); + try utf8.findGraphemeInfo(text, 4, false, .wcwidth, testing.allocator, &result); // Should have two entries for the CJK characters (each codepoint separately) try testing.expectEqual(@as(usize, 2), result.items.len); @@ -56,11 +56,11 @@ test "findGraphemeInfo wcwidth: CJK characters" { } test "findGraphemeInfo wcwidth: emoji with skin tone - each codepoint separate" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "👋🏿"; // Wave (4 bytes) + skin tone modifier (4 bytes) - try utf8.findGraphemeInfo(text, 4, false, .wcwidth, &result); + try utf8.findGraphemeInfo(text, 4, false, .wcwidth, testing.allocator, &result); // In wcwidth mode, these are TWO separate codepoints try testing.expectEqual(@as(usize, 2), result.items.len); @@ -77,11 +77,11 @@ test "findGraphemeInfo wcwidth: emoji with skin tone - each codepoint separate" } test "findGraphemeInfo wcwidth: emoji with ZWJ - each codepoint separate" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "👩‍🚀"; // Woman + ZWJ + Rocket - try utf8.findGraphemeInfo(text, 4, false, .wcwidth, &result); + try utf8.findGraphemeInfo(text, 4, false, .wcwidth, testing.allocator, &result); // In wcwidth mode, we see woman (width 2) and rocket (width 2) // ZWJ has width 0 so it's not in the list @@ -89,11 +89,11 @@ test "findGraphemeInfo wcwidth: emoji with ZWJ - each codepoint separate" { } test "findGraphemeInfo wcwidth: combining mark - base and mark separate" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "e\u{0301}test"; // e + combining acute accent - try utf8.findGraphemeInfo(text, 4, false, .wcwidth, &result); + try utf8.findGraphemeInfo(text, 4, false, .wcwidth, testing.allocator, &result); // In wcwidth mode, combining mark is a separate codepoint with width 0 // So we don't see it in the results (only non-zero width codepoints) @@ -102,15 +102,15 @@ test "findGraphemeInfo wcwidth: combining mark - base and mark separate" { } test "findGraphemeInfo wcwidth vs unicode: emoji with skin tone" { - var result_wcwidth = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result_wcwidth.deinit(); - var result_unicode = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result_unicode.deinit(); + var result_wcwidth: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result_wcwidth.deinit(testing.allocator); + var result_unicode: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result_unicode.deinit(testing.allocator); const text = "Hi👋🏿Bye"; - try utf8.findGraphemeInfo(text, 4, false, .wcwidth, &result_wcwidth); - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result_unicode); + try utf8.findGraphemeInfo(text, 4, false, .wcwidth, testing.allocator, &result_wcwidth); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result_unicode); // wcwidth: 2 codepoints (wave + skin tone) try testing.expectEqual(@as(usize, 2), result_wcwidth.items.len); @@ -122,15 +122,15 @@ test "findGraphemeInfo wcwidth vs unicode: emoji with skin tone" { } test "findGraphemeInfo wcwidth vs unicode: flag emoji" { - var result_wcwidth = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result_wcwidth.deinit(); - var result_unicode = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result_unicode.deinit(); + var result_wcwidth: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result_wcwidth.deinit(testing.allocator); + var result_unicode: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result_unicode.deinit(testing.allocator); const text = "🇺🇸"; // US flag (two regional indicators) - try utf8.findGraphemeInfo(text, 4, false, .wcwidth, &result_wcwidth); - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result_unicode); + try utf8.findGraphemeInfo(text, 4, false, .wcwidth, testing.allocator, &result_wcwidth); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result_unicode); // wcwidth: 2 codepoints (two regional indicators, each width 1) try testing.expectEqual(@as(usize, 2), result_wcwidth.items.len); diff --git a/packages/core/src/zig/text-buffer-segment.zig b/packages/core/src/zig/text-buffer-segment.zig index d59137744..dd6d3b961 100644 --- a/packages/core/src/zig/text-buffer-segment.zig +++ b/packages/core/src/zig/text-buffer-segment.zig @@ -94,13 +94,13 @@ pub const TextChunk = struct { const chunk_bytes = self.getBytes(mem_registry); - var grapheme_list = std.ArrayList(GraphemeInfo).init(allocator); - errdefer grapheme_list.deinit(); + var grapheme_list: std.ArrayListUnmanaged(GraphemeInfo) = .{}; + errdefer grapheme_list.deinit(allocator); - try utf8.findGraphemeInfo(chunk_bytes, tabwidth, self.isAsciiOnly(), width_method, &grapheme_list); + try utf8.findGraphemeInfo(chunk_bytes, tabwidth, self.isAsciiOnly(), width_method, allocator, &grapheme_list); // TODO: Calling this with an arena allocator will just double the memory usage? - const graphemes = try grapheme_list.toOwnedSlice(); + const graphemes = try grapheme_list.toOwnedSlice(allocator); mut_self.graphemes = graphemes; return graphemes; diff --git a/packages/core/src/zig/text-buffer.zig b/packages/core/src/zig/text-buffer.zig index 98b409dc4..c00b9ef6d 100644 --- a/packages/core/src/zig/text-buffer.zig +++ b/packages/core/src/zig/text-buffer.zig @@ -347,7 +347,7 @@ pub const UnifiedTextBuffer = struct { // The rope's boundary rewrite will handle normalization at join points var result = try self.textToSegments(self.global_allocator, text, mem_id, 0, false); - defer result.segments.deinit(); + defer result.segments.deinit(result.allocator); const insert_pos = self.rope.count(); try self.rope.insert_slice(insert_pos, result.segments.items); @@ -363,7 +363,7 @@ pub const UnifiedTextBuffer = struct { } var result = try self.textToSegments(self.global_allocator, text, mem_id, 0, true); - defer result.segments.deinit(); + defer result.segments.deinit(result.allocator); try self.rope.setSegments(result.segments.items); @@ -406,16 +406,16 @@ pub const UnifiedTextBuffer = struct { mem_id: u8, byte_offset: u32, prepend_linestart: bool, - ) TextBufferError!struct { segments: std.ArrayList(Segment), total_width: u32 } { + ) TextBufferError!struct { segments: std.ArrayListUnmanaged(Segment), total_width: u32, allocator: Allocator } { var break_result = utf8.LineBreakResult.init(allocator); defer break_result.deinit(); try utf8.findLineBreaks(text, &break_result); - var segments = std.ArrayList(Segment).init(allocator); - errdefer segments.deinit(); + var segments: std.ArrayListUnmanaged(Segment) = .{}; + errdefer segments.deinit(allocator); if (prepend_linestart) { - try segments.append(Segment{ .linestart = {} }); + try segments.append(allocator, Segment{ .linestart = {} }); } var local_start: u32 = 0; @@ -430,23 +430,23 @@ pub const UnifiedTextBuffer = struct { if (local_end > local_start) { const chunk = self.createChunk(mem_id, byte_offset + local_start, byte_offset + local_end); - try segments.append(Segment{ .text = chunk }); + try segments.append(allocator, Segment{ .text = chunk }); total_width += chunk.width; } - try segments.append(Segment{ .brk = {} }); - try segments.append(Segment{ .linestart = {} }); + try segments.append(allocator, Segment{ .brk = {} }); + try segments.append(allocator, Segment{ .linestart = {} }); local_start = break_pos + 1; } if (local_start < text.len) { const chunk = self.createChunk(mem_id, byte_offset + local_start, byte_offset + @as(u32, @intCast(text.len))); - try segments.append(Segment{ .text = chunk }); + try segments.append(allocator, Segment{ .text = chunk }); total_width += chunk.width; } - return .{ .segments = segments, .total_width = total_width }; + return .{ .segments = segments, .total_width = total_width, .allocator = allocator }; } pub fn getLineCount(self: *const Self) u32 { @@ -645,12 +645,12 @@ pub const UnifiedTextBuffer = struct { hl_idx: usize, }; - var events = std.ArrayList(Event).init(self.global_allocator); - defer events.deinit(); + var events: std.ArrayListUnmanaged(Event) = .{}; + defer events.deinit(self.global_allocator); for (highlights, 0..) |hl, idx| { - try events.append(.{ .col = hl.col_start, .is_start = true, .hl_idx = idx }); - try events.append(.{ .col = hl.col_end, .is_start = false, .hl_idx = idx }); + try events.append(self.global_allocator, .{ .col = hl.col_start, .is_start = true, .hl_idx = idx }); + try events.append(self.global_allocator, .{ .col = hl.col_end, .is_start = false, .hl_idx = idx }); } // Sort by column, ends before starts at same position diff --git a/packages/core/src/zig/utf8.zig b/packages/core/src/zig/utf8.zig index 605d1f842..390bd6000 100644 --- a/packages/core/src/zig/utf8.zig +++ b/packages/core/src/zig/utf8.zig @@ -60,16 +60,18 @@ pub const LineBreak = struct { }; pub const LineBreakResult = struct { - breaks: std.ArrayList(LineBreak), + breaks: std.ArrayListUnmanaged(LineBreak), + allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator) LineBreakResult { return .{ - .breaks = std.ArrayList(LineBreak).init(allocator), + .breaks = .{}, + .allocator = allocator, }; } pub fn deinit(self: *LineBreakResult) void { - self.breaks.deinit(); + self.breaks.deinit(self.allocator); } pub fn reset(self: *LineBreakResult) void { @@ -78,16 +80,18 @@ pub const LineBreakResult = struct { }; pub const TabStopResult = struct { - positions: std.ArrayList(usize), + positions: std.ArrayListUnmanaged(usize), + allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator) TabStopResult { return .{ - .positions = std.ArrayList(usize).init(allocator), + .positions = .{}, + .allocator = allocator, }; } pub fn deinit(self: *TabStopResult) void { - self.positions.deinit(); + self.positions.deinit(self.allocator); } pub fn reset(self: *TabStopResult) void { @@ -101,16 +105,18 @@ pub const WrapBreak = struct { }; pub const WrapBreakResult = struct { - breaks: std.ArrayList(WrapBreak), + breaks: std.ArrayListUnmanaged(WrapBreak), + allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator) WrapBreakResult { return .{ - .breaks = std.ArrayList(WrapBreak).init(allocator), + .breaks = .{}, + .allocator = allocator, }; } pub fn deinit(self: *WrapBreakResult) void { - self.breaks.deinit(); + self.breaks.deinit(self.allocator); } pub fn reset(self: *WrapBreakResult) void { @@ -233,7 +239,7 @@ pub fn findWrapBreaks(text: []const u8, result: *WrapBreakResult, width_method: // Use bit manipulation to extract positions while (bitmask != 0) { const bit_pos = @ctz(bitmask); - try result.breaks.append(.{ + try result.breaks.append(result.allocator, .{ .byte_offset = @intCast(pos + bit_pos), .char_offset = char_offset + @as(u16, @intCast(bit_pos)), }); @@ -261,7 +267,7 @@ pub fn findWrapBreaks(text: []const u8, result: *WrapBreakResult, width_method: } else true; if (isAsciiWrapBreak(b0)) { - try result.breaks.append(.{ + try result.breaks.append(result.allocator, .{ .byte_offset = @intCast(pos + i), .char_offset = char_offset, }); @@ -283,7 +289,7 @@ pub fn findWrapBreaks(text: []const u8, result: *WrapBreakResult, width_method: } else true; if (isUnicodeWrapBreak(dec.cp)) { - try result.breaks.append(.{ + try result.breaks.append(result.allocator, .{ .byte_offset = @intCast(pos + i), .char_offset = char_offset, }); @@ -310,7 +316,7 @@ pub fn findWrapBreaks(text: []const u8, result: *WrapBreakResult, width_method: } else true; if (isAsciiWrapBreak(b0)) { - try result.breaks.append(.{ + try result.breaks.append(result.allocator, .{ .byte_offset = @intCast(i), .char_offset = char_offset, }); @@ -330,7 +336,7 @@ pub fn findWrapBreaks(text: []const u8, result: *WrapBreakResult, width_method: } else true; if (isUnicodeWrapBreak(dec.cp)) { - try result.breaks.append(.{ + try result.breaks.append(result.allocator, .{ .byte_offset = @intCast(i), .char_offset = char_offset, }); @@ -361,7 +367,7 @@ pub fn findTabStops(text: []const u8, result: *TabStopResult) !void { var i: usize = 0; while (i < vector_len) : (i += 1) { if (text[pos + i] == '\t') { - try result.positions.append(pos + i); + try result.positions.append(result.allocator, pos + i); } } } @@ -370,7 +376,7 @@ pub fn findTabStops(text: []const u8, result: *TabStopResult) !void { while (pos < text.len) : (pos += 1) { if (text[pos] == '\t') { - try result.positions.append(pos); + try result.positions.append(result.allocator, pos); } } } @@ -408,14 +414,14 @@ pub fn findLineBreaks(text: []const u8, result: *LineBreakResult) !void { } // Check if this is part of CRLF const kind: LineBreakKind = if (absolute_index > 0 and text[absolute_index - 1] == '\r') .CRLF else .LF; - try result.breaks.append(.{ .pos = absolute_index, .kind = kind }); + try result.breaks.append(result.allocator, .{ .pos = absolute_index, .kind = kind }); } else if (b == '\r') { // Check for CRLF if (absolute_index + 1 < text.len and text[absolute_index + 1] == '\n') { - try result.breaks.append(.{ .pos = absolute_index + 1, .kind = .CRLF }); + try result.breaks.append(result.allocator, .{ .pos = absolute_index + 1, .kind = .CRLF }); i += 1; // Skip the \n in next iteration } else { - try result.breaks.append(.{ .pos = absolute_index, .kind = .CR }); + try result.breaks.append(result.allocator, .{ .pos = absolute_index, .kind = .CR }); } } } @@ -440,13 +446,13 @@ pub fn findLineBreaks(text: []const u8, result: *LineBreakResult) !void { } } const kind: LineBreakKind = if (pos > 0 and text[pos - 1] == '\r') .CRLF else .LF; - try result.breaks.append(.{ .pos = pos, .kind = kind }); + try result.breaks.append(result.allocator, .{ .pos = pos, .kind = kind }); } else if (b == '\r') { if (pos + 1 < text.len and text[pos + 1] == '\n') { - try result.breaks.append(.{ .pos = pos + 1, .kind = .CRLF }); + try result.breaks.append(result.allocator, .{ .pos = pos + 1, .kind = .CRLF }); pos += 1; } else { - try result.breaks.append(.{ .pos = pos, .kind = .CR }); + try result.breaks.append(result.allocator, .{ .pos = pos, .kind = .CR }); } } prev_was_cr = false; @@ -1711,11 +1717,12 @@ pub fn findGraphemeInfo( tab_width: u8, isASCIIOnly: bool, width_method: WidthMethod, - result: *std.ArrayList(GraphemeInfo), + allocator: std.mem.Allocator, + result: *std.ArrayListUnmanaged(GraphemeInfo), ) !void { switch (width_method) { - .unicode, .no_zwj => try findGraphemeInfoUnicode(text, tab_width, isASCIIOnly, width_method, result), - .wcwidth => try findGraphemeInfoWCWidth(text, tab_width, isASCIIOnly, result), + .unicode, .no_zwj => try findGraphemeInfoUnicode(text, tab_width, isASCIIOnly, width_method, allocator, result), + .wcwidth => try findGraphemeInfoWCWidth(text, tab_width, isASCIIOnly, allocator, result), } } @@ -1726,7 +1733,8 @@ fn findGraphemeInfoUnicode( tab_width: u8, isASCIIOnly: bool, width_method: WidthMethod, - result: *std.ArrayList(GraphemeInfo), + allocator: std.mem.Allocator, + result: *std.ArrayListUnmanaged(GraphemeInfo), ) !void { if (isASCIIOnly) { return; @@ -1768,7 +1776,7 @@ fn findGraphemeInfoUnicode( if (prev_cp != null and (cluster_is_multibyte or cluster_is_tab)) { if (cluster_width_state.width > 0 or width_method != .wcwidth) { const cluster_byte_len = (pos + i) - cluster_start; - try result.append(GraphemeInfo{ + try result.append(allocator, GraphemeInfo{ .byte_offset = @intCast(cluster_start), .byte_len = @intCast(cluster_byte_len), .width = @intCast(cluster_width_state.width), @@ -1819,7 +1827,7 @@ fn findGraphemeInfoUnicode( if (prev_cp != null and (cluster_is_multibyte or cluster_is_tab)) { if (cluster_width_state.width > 0 or width_method != .wcwidth) { const cluster_byte_len = (pos + i) - cluster_start; - try result.append(GraphemeInfo{ + try result.append(allocator, GraphemeInfo{ .byte_offset = @intCast(cluster_start), .byte_len = @intCast(cluster_byte_len), .width = @intCast(cluster_width_state.width), @@ -1870,7 +1878,7 @@ fn findGraphemeInfoUnicode( if (prev_cp != null and (cluster_is_multibyte or cluster_is_tab)) { if (cluster_width_state.width > 0 or width_method != .wcwidth) { const cluster_byte_len = pos - cluster_start; - try result.append(GraphemeInfo{ + try result.append(allocator, GraphemeInfo{ .byte_offset = @intCast(cluster_start), .byte_len = @intCast(cluster_byte_len), .width = @intCast(cluster_width_state.width), @@ -1908,7 +1916,7 @@ fn findGraphemeInfoUnicode( if (prev_cp != null and (cluster_is_multibyte or cluster_is_tab)) { if (cluster_width_state.width > 0 or width_method != .wcwidth) { const cluster_byte_len = text.len - cluster_start; - try result.append(GraphemeInfo{ + try result.append(allocator, GraphemeInfo{ .byte_offset = @intCast(cluster_start), .byte_len = @intCast(cluster_byte_len), .width = @intCast(cluster_width_state.width), @@ -1924,7 +1932,8 @@ fn findGraphemeInfoWCWidth( text: []const u8, tab_width: u8, isASCIIOnly: bool, - result: *std.ArrayList(GraphemeInfo), + allocator: std.mem.Allocator, + result: *std.ArrayListUnmanaged(GraphemeInfo), ) !void { if (isASCIIOnly) { return; @@ -1955,7 +1964,7 @@ fn findGraphemeInfoWCWidth( const is_multibyte = (cp_len != 1); if ((is_multibyte or is_tab) and cp_width > 0) { - try result.append(GraphemeInfo{ + try result.append(allocator, GraphemeInfo{ .byte_offset = @intCast(pos), .byte_len = @intCast(cp_len), .width = @intCast(cp_width), diff --git a/packages/core/src/zig/vterm.zig b/packages/core/src/zig/vterm.zig new file mode 100644 index 000000000..caced5eea --- /dev/null +++ b/packages/core/src/zig/vterm.zig @@ -0,0 +1,619 @@ +const std = @import("std"); +const ghostty_vt = @import("ghostty-vt"); +const color = ghostty_vt.color; +const pagepkg = ghostty_vt.page; +const formatter = ghostty_vt.formatter; +const Screen = ghostty_vt.Screen; + +// Reusable arena for stateless functions (ptyToJson, ptyToText). +// Reset after each call to reuse allocated pages - avoids mmap/munmap per call. +var stateless_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + +pub const StyleFlags = packed struct(u8) { + bold: bool = false, + italic: bool = false, + underline: bool = false, + strikethrough: bool = false, + inverse: bool = false, + faint: bool = false, + _padding: u2 = 0, + + pub fn toInt(self: StyleFlags) u8 { + return @bitCast(self); + } + + pub fn eql(self: StyleFlags, other: StyleFlags) bool { + return self.toInt() == other.toInt(); + } +}; + +pub const CellStyle = struct { + fg: ?color.RGB, + bg: ?color.RGB, + flags: StyleFlags, + + pub fn eql(self: CellStyle, other: CellStyle) bool { + const fg_eq = if (self.fg) |a| (if (other.fg) |b| a.r == b.r and a.g == b.g and a.b == b.b else false) else other.fg == null; + const bg_eq = if (self.bg) |a| (if (other.bg) |b| a.r == b.r and a.g == b.g and a.b == b.b else false) else other.bg == null; + return fg_eq and bg_eq and self.flags.eql(other.flags); + } +}; + +fn getStyleFromCell( + cell: *const pagepkg.Cell, + pin: ghostty_vt.Pin, + palette: *const color.Palette, + terminal_bg: ?color.RGB, +) CellStyle { + var flags: StyleFlags = .{}; + var fg: ?color.RGB = null; + var bg: ?color.RGB = null; + + const style = pin.style(cell); + + flags.bold = style.flags.bold; + flags.italic = style.flags.italic; + flags.faint = style.flags.faint; + flags.inverse = style.flags.inverse; + flags.strikethrough = style.flags.strikethrough; + flags.underline = style.flags.underline != .none; + + fg = switch (style.fg_color) { + .none => null, + .palette => |idx| palette[idx], + .rgb => |rgb| rgb, + }; + + bg = style.bg(cell, palette) orelse switch (cell.content_tag) { + .bg_color_palette => palette[cell.content.color_palette], + .bg_color_rgb => .{ .r = cell.content.color_rgb.r, .g = cell.content.color_rgb.g, .b = cell.content.color_rgb.b }, + else => null, + }; + + if (bg) |cell_bg| { + if (terminal_bg) |term_bg| { + if (cell_bg.r == term_bg.r and cell_bg.g == term_bg.g and cell_bg.b == term_bg.b) { + bg = null; + } + } + } + + return .{ .fg = fg, .bg = bg, .flags = flags }; +} + +fn writeJsonString(writer: anytype, s: []const u8) !void { + try writer.writeByte('"'); + for (s) |c| { + switch (c) { + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\n"), + '\r' => try writer.writeAll("\\r"), + '\t' => try writer.writeAll("\\t"), + else => { + if (c < 0x20) { + try writer.print("\\u{x:0>4}", .{c}); + } else { + try writer.writeByte(c); + } + }, + } + } + try writer.writeByte('"'); +} + +fn writeColor(writer: anytype, rgb: ?color.RGB) !void { + if (rgb) |c| { + try writer.print("\"#{x:0>2}{x:0>2}{x:0>2}\"", .{ c.r, c.g, c.b }); + } else { + try writer.writeAll("null"); + } +} + +fn countLines(screen: *Screen) usize { + var total: usize = 0; + var iter = screen.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + while (iter.next()) |_| { + total += 1; + } + return total; +} + +fn hasEnoughLines(screen: *Screen, threshold: usize) bool { + var count: usize = 0; + var iter = screen.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + while (iter.next()) |_| { + count += 1; + if (count >= threshold) return true; + } + return false; +} + +pub fn writeJsonOutput( + writer: anytype, + t: *ghostty_vt.Terminal, + offset: usize, + limit: ?usize, + show_cursor: bool, +) !void { + const screen = t.screens.active; + const palette = &t.colors.palette.current; + const terminal_bg = t.colors.background.get(); + + const total_lines = countLines(screen); + + // Calculate cursor row in absolute screen coordinates (for inverting cursor cell) + const cursor_abs_row: ?usize = if (show_cursor) blk: { + const rows: usize = screen.pages.rows; + const viewport_start = if (total_lines >= rows) total_lines - rows else 0; + break :blk viewport_start + screen.cursor.y; + } else null; + const cursor_col: usize = screen.cursor.x; + + try writer.writeAll("{"); + try writer.print("\"cols\":{},\"rows\":{},", .{ screen.pages.cols, screen.pages.rows }); + try writer.print("\"cursor\":[{},{}],", .{ screen.cursor.x, screen.cursor.y }); + try writer.print("\"offset\":{},\"totalLines\":{},", .{ offset, total_lines }); + try writer.writeAll("\"lines\":["); + + var text_buf: [4096]u8 = undefined; + var row_iter = screen.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + var row_idx: usize = 0; + var output_idx: usize = 0; + + while (row_iter.next()) |pin| { + if (row_idx < offset) { + row_idx += 1; + continue; + } + + if (limit) |lim| { + if (output_idx >= lim) break; + } + + if (output_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + + const cells = pin.cells(.all); + var span_start: usize = 0; + var span_len: usize = 0; + var current_style: ?CellStyle = null; + var text_len: usize = 0; + var span_idx: usize = 0; + + // Check if cursor is on this row + const is_cursor_row = if (cursor_abs_row) |crow| row_idx == crow else false; + + for (cells, 0..) |*cell, col_idx| { + if (cell.wide == .spacer_tail) continue; + + const cp = cell.codepoint(); + const is_null = cp == 0; + + // Check if this cell is at cursor position + const is_cursor_cell = is_cursor_row and col_idx == cursor_col; + + // Handle cursor on empty cell - emit a single-char inverted span + if (is_null and is_cursor_cell) { + // First flush any pending span + if (text_len > 0) { + if (span_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + try writeJsonString(writer, text_buf[0..text_len]); + try writer.writeByte(','); + try writeColor(writer, current_style.?.fg); + try writer.writeByte(','); + try writeColor(writer, current_style.?.bg); + try writer.print(",{},{}", .{ current_style.?.flags.toInt(), span_len }); + try writer.writeByte(']'); + span_idx += 1; + text_len = 0; + span_len = 0; + current_style = null; + } + // Emit cursor span with space and inverse flag + if (span_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + try writeJsonString(writer, " "); + try writer.writeAll(",null,null,"); + const cursor_flags = StyleFlags{ .inverse = true }; + try writer.print("{},1", .{cursor_flags.toInt()}); + try writer.writeByte(']'); + span_idx += 1; + continue; + } + + if (is_null) { + if (text_len > 0) { + if (span_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + try writeJsonString(writer, text_buf[0..text_len]); + try writer.writeByte(','); + try writeColor(writer, current_style.?.fg); + try writer.writeByte(','); + try writeColor(writer, current_style.?.bg); + try writer.print(",{},{}", .{ current_style.?.flags.toInt(), span_len }); + try writer.writeByte(']'); + span_idx += 1; + text_len = 0; + span_len = 0; + } + current_style = null; + continue; + } + + var style = getStyleFromCell(cell, pin, palette, terminal_bg); + + // Toggle inverse for cursor cell + if (is_cursor_cell) { + style.flags.inverse = !style.flags.inverse; + } + + const style_changed = if (current_style) |cs| !cs.eql(style) else true; + + if (style_changed and text_len > 0) { + if (span_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + try writeJsonString(writer, text_buf[0..text_len]); + try writer.writeByte(','); + try writeColor(writer, current_style.?.fg); + try writer.writeByte(','); + try writeColor(writer, current_style.?.bg); + try writer.print(",{},{}", .{ current_style.?.flags.toInt(), span_len }); + try writer.writeByte(']'); + span_idx += 1; + text_len = 0; + span_len = 0; + } + + if (style_changed) { + span_start = col_idx; + current_style = style; + } + + const cp21: u21 = @intCast(cp); + const len = std.unicode.utf8CodepointSequenceLength(cp21) catch 1; + if (text_len + len <= text_buf.len) { + _ = std.unicode.utf8Encode(cp21, text_buf[text_len..]) catch 0; + text_len += len; + } + + span_len += if (cell.wide == .wide) 2 else 1; + } + + if (text_len > 0) { + if (span_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + try writeJsonString(writer, text_buf[0..text_len]); + try writer.writeByte(','); + try writeColor(writer, current_style.?.fg); + try writer.writeByte(','); + try writeColor(writer, current_style.?.bg); + try writer.print(",{},{}", .{ current_style.?.flags.toInt(), span_len }); + try writer.writeByte(']'); + } + + try writer.writeByte(']'); + row_idx += 1; + output_idx += 1; + } + + try writer.writeAll("]}"); +} + +const ReadonlyStream = @typeInfo(@TypeOf(ghostty_vt.Terminal.vtStream)).@"fn".return_type.?; + +pub const PersistentTerminal = struct { + terminal: ghostty_vt.Terminal, + arena: std.heap.ArenaAllocator, + stream: ?ReadonlyStream, + + /// Create an uninitialized PersistentTerminal. Must call initTerminal() after + /// the struct is in its final memory location (heap-allocated). + pub fn create(backing_alloc: std.mem.Allocator) PersistentTerminal { + return .{ + .terminal = undefined, + .arena = std.heap.ArenaAllocator.init(backing_alloc), + .stream = null, + }; + } + + /// Initialize the terminal. Must be called after the struct is heap-allocated + /// so the arena's address is stable when stored in the terminal. + pub fn initTerminal(self: *PersistentTerminal, cols: u16, rows: u16) !void { + self.terminal = try ghostty_vt.Terminal.init(self.arena.allocator(), .{ + .cols = cols, + .rows = rows, + .max_scrollback = std.math.maxInt(usize), + }); + self.terminal.modes.set(.linefeed, true); + } + + pub fn initStream(self: *PersistentTerminal) void { + self.stream = self.terminal.vtStream(); + } + + pub fn deinit(self: *PersistentTerminal) void { + // Arena deinit frees everything: terminal internals, stream, and output strings + self.arena.deinit(); + } + + pub fn allocator(self: *PersistentTerminal) std.mem.Allocator { + return self.arena.allocator(); + } + + pub fn feed(self: *PersistentTerminal, data: []const u8) !void { + try self.stream.?.nextSlice(data); + } + + pub fn isReady(self: *const PersistentTerminal) bool { + if (self.stream) |s| { + return s.parser.state == .ground; + } + return true; + } + + pub fn resize(self: *PersistentTerminal, cols: u16, rows: u16) !void { + try self.terminal.resize(self.arena.allocator(), cols, rows); + } + + pub fn reset(self: *PersistentTerminal) void { + self.terminal.fullReset(); + if (self.stream) |*s| { + s.deinit(); + } + self.stream = self.terminal.vtStream(); + } +}; + +var terminals_mutex: std.Thread.Mutex = .{}; +var terminals: ?std.AutoHashMap(u32, *PersistentTerminal) = null; + +fn getTerminalsMap() *std.AutoHashMap(u32, *PersistentTerminal) { + if (terminals == null) { + terminals = std.AutoHashMap(u32, *PersistentTerminal).init(std.heap.page_allocator); + } + return &terminals.?; +} + +/// Stateless: parse PTY input and write JSON to caller-provided buffer. +/// Returns bytes written, or 0 on error. +pub fn ptyToJson( + input_ptr: [*]const u8, + input_len: usize, + cols: u16, + rows: u16, + offset: usize, + limit: usize, + out_ptr: [*]u8, + max_len: usize, +) usize { + // Reset arena after use - keeps allocated pages for next call + defer _ = stateless_arena.reset(.retain_capacity); + const alloc = stateless_arena.allocator(); + + const input = input_ptr[0..input_len]; + const lim: ?usize = if (limit == 0) null else limit; + const out_buffer = out_ptr[0..max_len]; + + var t: ghostty_vt.Terminal = ghostty_vt.Terminal.init(alloc, .{ + .cols = cols, + .rows = rows, + .max_scrollback = std.math.maxInt(usize), + }) catch return 0; + + t.modes.set(.linefeed, true); + + var stream = t.vtStream(); + defer stream.deinit(); + + if (lim) |line_limit| { + const chunk_size: usize = 4096; + const threshold = line_limit + offset + 20; + var pos: usize = 0; + + while (pos < input.len) { + const end = @min(pos + chunk_size, input.len); + stream.nextSlice(input[pos..end]) catch return 0; + pos = end; + + if (stream.parser.state == .ground) { + if (hasEnoughLines(t.screens.active, threshold)) { + break; + } + } + } + } else { + stream.nextSlice(input) catch return 0; + } + + // Write directly to the caller-provided buffer + var fbs = std.io.fixedBufferStream(out_buffer); + writeJsonOutput(fbs.writer(), &t, offset, lim, false) catch return 0; + + return fbs.pos; +} + +/// Stateless: parse PTY input and write plain text to caller-provided buffer. +/// Returns bytes written, or 0 on error. +pub fn ptyToText( + input_ptr: [*]const u8, + input_len: usize, + cols: u16, + rows: u16, + out_ptr: [*]u8, + max_len: usize, +) usize { + // Reset arena after use - keeps allocated pages for next call + defer _ = stateless_arena.reset(.retain_capacity); + const alloc = stateless_arena.allocator(); + + const input = input_ptr[0..input_len]; + const out_buffer = out_ptr[0..max_len]; + + var t: ghostty_vt.Terminal = ghostty_vt.Terminal.init(alloc, .{ + .cols = cols, + .rows = rows, + .max_scrollback = std.math.maxInt(usize), + }) catch return 0; + + t.modes.set(.linefeed, true); + + var stream = t.vtStream(); + defer stream.deinit(); + + stream.nextSlice(input) catch return 0; + + // TerminalFormatter requires std.Io.Writer.Allocating, so write to temp buffer first + var builder: std.Io.Writer.Allocating = .init(alloc); + var fmt: formatter.TerminalFormatter = formatter.TerminalFormatter.init(&t, .plain); + fmt.format(&builder.writer) catch return 0; + + const temp_output = builder.writer.buffered(); + const copy_len = @min(temp_output.len, max_len); + @memcpy(out_buffer[0..copy_len], temp_output[0..copy_len]); + + return copy_len; +} + +pub fn createTerminal(id: u32, cols: u32, rows: u32) bool { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + + if (map.get(id)) |existing| { + existing.deinit(); + std.heap.page_allocator.destroy(existing); + _ = map.remove(id); + } + + // Two-phase init: first allocate struct to heap, then init terminal in-place. + // This ensures the arena's address is stable when stored in the terminal. + const term_ptr = std.heap.page_allocator.create(PersistentTerminal) catch return false; + term_ptr.* = PersistentTerminal.create(std.heap.page_allocator); + + term_ptr.initTerminal(@intCast(cols), @intCast(rows)) catch { + term_ptr.arena.deinit(); + std.heap.page_allocator.destroy(term_ptr); + return false; + }; + + term_ptr.initStream(); + + map.put(id, term_ptr) catch { + term_ptr.deinit(); + std.heap.page_allocator.destroy(term_ptr); + return false; + }; + + return true; +} + +pub fn destroyTerminal(id: u32) void { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + if (map.get(id)) |term| { + term.deinit(); + std.heap.page_allocator.destroy(term); + _ = map.remove(id); + } +} + +pub fn feedTerminal(id: u32, data_ptr: [*]const u8, data_len: usize) bool { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return false; + term.feed(data_ptr[0..data_len]) catch return false; + return true; +} + +pub fn resizeTerminal(id: u32, cols: u32, rows: u32) bool { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return false; + term.resize(@intCast(cols), @intCast(rows)) catch return false; + return true; +} + +pub fn resetTerminal(id: u32) bool { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return false; + term.reset(); + return true; +} + +/// Write terminal JSON to caller-provided buffer. Returns bytes written. +pub fn getTerminalJson(id: u32, offset: u32, limit: u32, out_ptr: [*]u8, max_len: usize) usize { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return 0; + + const lim: ?usize = if (limit == 0) null else @intCast(limit); + const out_buffer = out_ptr[0..max_len]; + + var fbs = std.io.fixedBufferStream(out_buffer); + writeJsonOutput(fbs.writer(), &term.terminal, @intCast(offset), lim, true) catch return 0; + + return fbs.pos; +} + +/// Write terminal plain text to caller-provided buffer. Returns bytes written. +pub fn getTerminalText(id: u32, out_ptr: [*]u8, max_len: usize) usize { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return 0; + + const out_buffer = out_ptr[0..max_len]; + + // TerminalFormatter requires std.Io.Writer.Allocating, so write to temp buffer first + var builder: std.Io.Writer.Allocating = .init(term.allocator()); + var fmt: formatter.TerminalFormatter = formatter.TerminalFormatter.init(&term.terminal, .plain); + fmt.format(&builder.writer) catch return 0; + + const temp_output = builder.writer.buffered(); + const copy_len = @min(temp_output.len, max_len); + @memcpy(out_buffer[0..copy_len], temp_output[0..copy_len]); + + return copy_len; +} + +/// Write terminal cursor position JSON to caller-provided buffer. Returns bytes written. +pub fn getTerminalCursor(id: u32, out_ptr: [*]u8, max_len: usize) usize { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return 0; + + const screen = term.terminal.screens.active; + const out_buffer = out_ptr[0..max_len]; + + var fbs = std.io.fixedBufferStream(out_buffer); + fbs.writer().print("[{},{}]", .{ screen.cursor.x, screen.cursor.y }) catch return 0; + + return fbs.pos; +} + +pub fn isTerminalReady(id: u32) i32 { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return -1; + + return if (term.isReady()) 1 else 0; +} diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 94f568c12..69905c328 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -7,7 +7,9 @@ import { LineNumberRenderable, ScrollBoxRenderable, SelectRenderable, + StatelessTerminalRenderable, TabSelectRenderable, + TerminalRenderable, TextareaRenderable, TextRenderable, } from "@opentui/core" @@ -30,6 +32,8 @@ export const baseComponents = { select: SelectRenderable, textarea: TextareaRenderable, scrollbox: ScrollBoxRenderable, + terminal: TerminalRenderable, + "stateless-terminal": StatelessTerminalRenderable, "ascii-font": ASCIIFontRenderable, "tab-select": TabSelectRenderable, "line-number": LineNumberRenderable, diff --git a/packages/react/src/types/components.ts b/packages/react/src/types/components.ts index fe63d1142..8e40ad156 100644 --- a/packages/react/src/types/components.ts +++ b/packages/react/src/types/components.ts @@ -19,9 +19,13 @@ import type { SelectOption, SelectRenderable, SelectRenderableOptions, + StatelessTerminalOptions, + StatelessTerminalRenderable, TabSelectOption, TabSelectRenderable, TabSelectRenderableOptions, + TerminalOptions, + TerminalRenderable, TextareaOptions, TextareaRenderable, TextNodeOptions, @@ -144,6 +148,10 @@ export type CodeProps = ComponentProps export type DiffProps = ComponentProps +export type TerminalProps = ComponentProps + +export type StatelessTerminalProps = ComponentProps + export type SelectProps = ComponentProps & { focused?: boolean onChange?: (index: number, option: SelectOption | null) => void diff --git a/packages/solid/examples/components/ExampleSelector.tsx b/packages/solid/examples/components/ExampleSelector.tsx index bbaa002ab..7dac7e3e8 100644 --- a/packages/solid/examples/components/ExampleSelector.tsx +++ b/packages/solid/examples/components/ExampleSelector.tsx @@ -13,11 +13,17 @@ import MouseScene from "./mouse-demo.tsx" import { ScrollDemo, ScrollDemoIndex } from "./scroll-demo.tsx" import { CustomScrollAccelDemo } from "./custom-scroll-accel-demo.tsx" import TabSelectDemo from "./tab-select-demo.tsx" +import TerminalGridDemo from "./terminal-grid-demo.tsx" import TextSelectionDemo from "./text-selection-demo.tsx" import TextStyleScene from "./text-style-demo.tsx" import { TextareaDemo } from "./textarea-demo.tsx" const EXAMPLES = [ + { + name: "Terminal Grid Demo", + description: "2x2 grid of interactive terminals with focus switching", + scene: "terminal-grid-demo", + }, { name: "Diff Viewer Demo", description: "Unified and split diff view with syntax highlighting", @@ -150,6 +156,9 @@ const ExampleSelector = () => { return ( + + + diff --git a/packages/solid/examples/components/terminal-grid-demo.tsx b/packages/solid/examples/components/terminal-grid-demo.tsx new file mode 100644 index 000000000..bdf4d4d4f --- /dev/null +++ b/packages/solid/examples/components/terminal-grid-demo.tsx @@ -0,0 +1,171 @@ +import { type KeyEvent } from "@opentui/core" +import { spawn, type IPty } from "bun-pty" +import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { createSignal, onCleanup, onMount, For, createEffect } from "solid-js" + +interface TerminalStream { + readable: ReadableStream + writable: WritableStream + pty: IPty +} + +const GRID_COLS = 2 +const GRID_ROWS = 2 +const TOTAL_TERMINALS = GRID_COLS * GRID_ROWS + +async function spawnPty(cols: number, rows: number) { + try { + return spawn("opencode", [], { + name: "xterm-256color", + cols, + rows, + cwd: process.cwd(), + env: { ...process.env, TERM: "xterm-256color" }, + }) + } catch (e) { + console.error("Failed to spawn PTY:", e) + return null + } +} + +export default function TerminalGridDemo() { + const renderer = useRenderer() + const dims = useTerminalDimensions() + + const [focusedIndex, setFocusedIndex] = createSignal(0) + const [status, setStatus] = createSignal("Initializing...") + const [streams, setStreams] = createSignal<(TerminalStream | null)[]>([]) + + const terminalCols = () => Math.floor((dims().width - 4) / GRID_COLS) - 2 + const terminalRows = () => Math.floor((dims().height - 5) / GRID_ROWS) - 2 + + onMount(async () => { + renderer.useMouse = true + + const newStreams: (TerminalStream | null)[] = [] + + for (let i = 0; i < TOTAL_TERMINALS; i++) { + const pty = await spawnPty(terminalCols(), terminalRows()) + + if (pty) { + const readable = new ReadableStream({ + start(controller) { + pty.onData((data) => controller.enqueue(data)) + + pty.onExit(() => controller.close()) + }, + }) + + const writable = new WritableStream({ + write(chunk) { + pty.write(chunk) + }, + }) + + newStreams.push({ readable, writable, pty }) + } else { + newStreams.push(null) + } + } + + setStreams(newStreams) + setStatus(`${TOTAL_TERMINALS} terminals ready - Tab to switch focus, Ctrl+Q to quit`) + }) + + onCleanup(() => { + for (const stream of streams()) { + stream?.pty.kill() + } + }) + + createEffect(() => { + const cols = terminalCols() + const rows = terminalRows() + for (const stream of streams()) { + stream?.pty.resize(cols, rows) + } + }) + + useKeyboard((key: KeyEvent) => { + if (key.name === "tab") { + if (key.shift) { + setFocusedIndex((prev) => (prev - 1 + TOTAL_TERMINALS) % TOTAL_TERMINALS) + } else { + setFocusedIndex((prev) => (prev + 1) % TOTAL_TERMINALS) + } + return + } + + if (key.ctrl && key.name === "q") { + for (const stream of streams()) { + stream?.pty.kill() + } + renderer.stop() + process.exit(0) + } + + const stream = streams()[focusedIndex()] + if (stream && key.raw) { + stream.pty.write(key.raw) + } + }) + + const handleClick = (index: number) => () => setFocusedIndex(index) + + return ( + + + + + + + + + + + i)}> + {(row) => ( + + i)}> + {(col) => { + const index = row * GRID_COLS + col + const stream = () => streams()[index] + const isFocused = () => focusedIndex() === index + + return ( + + {stream() && ( + + )} + + ) + }} + + + )} + + + + ) +} diff --git a/packages/solid/examples/index.tsx b/packages/solid/examples/index.tsx index cdebd894e..1e90868b3 100644 --- a/packages/solid/examples/index.tsx +++ b/packages/solid/examples/index.tsx @@ -1,6 +1,6 @@ import { render } from "@opentui/solid" import { ConsolePosition } from "@opentui/core" -import ExampleSelector from "./components/ExampleSelector" +import ExampleSelector from "./components/terminal-grid-demo" // Uncomment to debug solidjs reconciler // process.env.DEBUG = "true" diff --git a/packages/solid/src/elements/index.ts b/packages/solid/src/elements/index.ts index bf46a04d5..ed27b1599 100644 --- a/packages/solid/src/elements/index.ts +++ b/packages/solid/src/elements/index.ts @@ -7,7 +7,9 @@ import { LineNumberRenderable, ScrollBoxRenderable, SelectRenderable, + StatelessTerminalRenderable, TabSelectRenderable, + TerminalRenderable, TextareaRenderable, TextAttributes, TextNodeRenderable, @@ -102,6 +104,8 @@ export const baseComponents = { code: CodeRenderable, diff: DiffRenderable, line_number: LineNumberRenderable, + terminal: TerminalRenderable, + stateless_terminal: StatelessTerminalRenderable, span: SpanRenderable, strong: BoldSpanRenderable, diff --git a/packages/vue/src/elements.ts b/packages/vue/src/elements.ts index 2bdec94a3..505f9f574 100644 --- a/packages/vue/src/elements.ts +++ b/packages/vue/src/elements.ts @@ -3,7 +3,9 @@ import { BoxRenderable, InputRenderable, SelectRenderable, + StatelessTerminalRenderable, TabSelectRenderable, + TerminalRenderable, TextRenderable, ScrollBoxRenderable, } from "@opentui/core" @@ -14,6 +16,8 @@ export const elements = { inputRenderable: InputRenderable, selectRenderable: SelectRenderable, tabSelectRenderable: TabSelectRenderable, + terminalRenderable: TerminalRenderable, + statelessTerminalRenderable: StatelessTerminalRenderable, textRenderable: TextRenderable, scrollBoxRenderable: ScrollBoxRenderable, }