diff --git a/packages/core/src/output-strategy.test.ts b/packages/core/src/output-strategy.test.ts new file mode 100644 index 000000000..c10bc9d32 --- /dev/null +++ b/packages/core/src/output-strategy.test.ts @@ -0,0 +1,48 @@ +import { EventEmitter } from "events" +import { expect, test } from "bun:test" +import type { Pointer } from "bun:ffi" + +import { createOutputStrategy } from "./output-strategy" +import type { RenderLib } from "./zig" +import { createCapturingStdout } from "./testing/stdout-mocks" + +test("javascript output strategy flushes via provided write function", () => { + const frame = Buffer.from("frame-bytes") + const writes: Buffer[] = [] + + const stdout = createCapturingStdout() + stdout.write = () => { + throw new Error("stdout.write should not be hit when writeToTerminal is provided") + } + + const stdin = new EventEmitter() as unknown as NodeJS.ReadStream + + const libMock = { + setWriteTarget: () => {}, + getWriteBufferLength: () => frame.length, + copyWriteBuffer: (_renderer: Pointer, target: Uint8Array) => { + target.set(frame) + return frame.length + }, + } as unknown as RenderLib + + const writeToTerminal = (chunk: any) => { + writes.push(Buffer.from(chunk)) + return true + } + + const strategy = createOutputStrategy("javascript", { + stdout, + stdin, + lib: libMock, + rendererPtr: 0 as Pointer, + writeToTerminal, + emitFlush: () => {}, + onDrain: () => {}, + }) + + strategy.flush("test") + + expect(writes).toHaveLength(1) + expect(writes[0].toString()).toBe(frame.toString()) +}) diff --git a/packages/core/src/output-strategy.ts b/packages/core/src/output-strategy.ts new file mode 100644 index 000000000..afc7f566e --- /dev/null +++ b/packages/core/src/output-strategy.ts @@ -0,0 +1,184 @@ +import type { Pointer } from "bun:ffi" +import type { RenderLib } from "./zig" + +export type StdoutWrite = NodeJS.WriteStream["write"] + +enum NativeWriteTarget { + TTY = 0, + BUFFER = 1, +} + +export interface OutputStrategyOptions { + stdout: NodeJS.WriteStream + stdin: NodeJS.ReadStream + lib: RenderLib + rendererPtr: Pointer + writeToTerminal: StdoutWrite + emitFlush: (event: { bytes: number; reason: string }) => void + onDrain: () => void +} + +export interface OutputStrategy { + flush(reason: string): void + canRender(): boolean + setup(useAlternateScreen: boolean, processCapabilityResponse: (data: string) => void): Promise + teardown(): void + render(force: boolean): void + destroy(): void +} + +async function setupTerminalWithCapabilities( + stdin: NodeJS.ReadStream, + onCapability: (data: string) => void, + setupFn: () => void, +): Promise { + await new Promise((resolve) => { + const timeout = setTimeout(() => { + stdin.off("data", capListener) + resolve() + }, 100) + const capListener = (str: string) => { + clearTimeout(timeout) + onCapability(str) + stdin.off("data", capListener) + resolve() + } + stdin.on("data", capListener) + setupFn() + }) +} + +class NativeOutputStrategy implements OutputStrategy { + constructor(private options: OutputStrategyOptions) {} + + flush(_reason: string): void { + // no-op - native handles flushing + } + + canRender(): boolean { + return true // never blocked + } + + async setup(useAlternateScreen: boolean, processCapabilityResponse: (data: string) => void): Promise { + await setupTerminalWithCapabilities(this.options.stdin, processCapabilityResponse, () => + this.options.lib.setupTerminal(this.options.rendererPtr, useAlternateScreen), + ) + } + + teardown(): void { + // no-op - handled elsewhere + } + + render(force: boolean): void { + this.options.lib.render(this.options.rendererPtr, force) + } + + destroy(): void { + // no-op - nothing to clean up + } +} + +class JavaScriptOutputStrategy implements OutputStrategy { + private nativeWriteBuffer: Uint8Array = new Uint8Array(0) + private awaitingDrain: boolean = false + private drainListener: (() => void) | null = null + + constructor(private options: OutputStrategyOptions) { + options.lib.setWriteTarget(options.rendererPtr, NativeWriteTarget.BUFFER) + } + + flush(reason: string): void { + const chunk = this.readNativeBuffer() + if (!chunk || chunk.length === 0) { + return + } + const wrote = this.options.writeToTerminal(chunk) + this.options.emitFlush({ bytes: chunk.length, reason }) + if (!wrote) { + this.scheduleDrain() + } + } + + canRender(): boolean { + return !this.awaitingDrain + } + + async setup(useAlternateScreen: boolean, processCapabilityResponse: (data: string) => void): Promise { + await setupTerminalWithCapabilities( + this.options.stdin, + (str: string) => { + processCapabilityResponse(str) + this.flush("capabilities") + }, + () => { + this.options.lib.setupTerminalToBuffer(this.options.rendererPtr, useAlternateScreen) + this.flush("setup") + }, + ) + } + + teardown(): void { + this.options.lib.teardownTerminalToBuffer(this.options.rendererPtr) + this.flush("teardown") + } + + render(force: boolean): void { + this.options.lib.renderIntoWriteBuffer(this.options.rendererPtr, force) + this.flush("frame") + } + + destroy(): void { + if (this.awaitingDrain && this.drainListener) { + this.options.stdout.off?.("drain", this.drainListener) + this.drainListener = null + this.awaitingDrain = false + } + } + + private ensureNativeWriteBufferSize(size: number): void { + if (this.nativeWriteBuffer.length >= size) { + return + } + const nextSize = Math.max(size, this.nativeWriteBuffer.length > 0 ? this.nativeWriteBuffer.length * 2 : 4096) + this.nativeWriteBuffer = new Uint8Array(nextSize) + } + + private readNativeBuffer(): Uint8Array | null { + const length = this.options.lib.getWriteBufferLength(this.options.rendererPtr) + if (!length) { + return null + } + this.ensureNativeWriteBufferSize(length) + const copied = this.options.lib.copyWriteBuffer(this.options.rendererPtr, this.nativeWriteBuffer) + if (!copied) { + return null + } + return this.nativeWriteBuffer.subarray(0, copied) + } + + private scheduleDrain(): void { + if (this.awaitingDrain || typeof this.options.stdout.once !== "function") { + return + } + this.awaitingDrain = true + this.drainListener = this.handleDrain + this.options.stdout.once("drain", this.handleDrain) + } + + private handleDrain = (): void => { + this.awaitingDrain = false + if (this.drainListener) { + this.drainListener = null + } + this.options.onDrain() + } +} + +export type OutputMode = "native" | "javascript" + +export function createOutputStrategy(mode: OutputMode, options: OutputStrategyOptions): OutputStrategy { + if (mode === "javascript") { + return new JavaScriptOutputStrategy(options) + } + return new NativeOutputStrategy(options) +} diff --git a/packages/core/src/renderer.stdout.test.ts b/packages/core/src/renderer.stdout.test.ts new file mode 100644 index 000000000..52e36b5fa --- /dev/null +++ b/packages/core/src/renderer.stdout.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test" + +import { capture } from "./console" +import { createTestRenderer } from "./testing/test-renderer" +import { createCapturingStdout } from "./testing/stdout-mocks" + +test("javascript mode keeps stdout interception active and capture records writes", async () => { + const mockStdout = createCapturingStdout() + const originalWrite = mockStdout.write + + const { renderer } = await createTestRenderer({ + outputMode: "javascript", + stdout: mockStdout, + disableStdoutInterception: false, + }) + + try { + expect(mockStdout.write).not.toBe(originalWrite) + + capture.claimOutput() + mockStdout.write("external log\n") + expect(capture.claimOutput()).toBe("external log\n") + + ;(renderer as any).writeOut("frame bytes\n") + expect(mockStdout.written).toContain("frame bytes\n") + } finally { + renderer.destroy() + } +}) diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 990ca884f..6baf62fb8 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -20,6 +20,12 @@ import { getObjectsInViewport } from "./lib/objects-in-viewport" import { KeyHandler, InternalKeyHandler } from "./lib/KeyHandler" import { env, registerEnvVar } from "./lib/env" import { getTreeSitterClient } from "./lib/tree-sitter" +import { + createOutputStrategy, + type OutputStrategy, + type OutputMode, + type StdoutWrite, +} from "./output-strategy" registerEnvVar({ name: "OTUI_DUMP_CAPTURES", @@ -52,6 +58,7 @@ registerEnvVar({ export interface CliRendererConfig { stdin?: NodeJS.ReadStream stdout?: NodeJS.WriteStream + outputMode?: OutputMode exitOnCtrlC?: boolean debounceDelay?: number targetFps?: number @@ -180,7 +187,7 @@ export async function createCliRenderer(config: CliRendererConfig = {}): Promise // Disable threading on linux because there currently is currently an issue // might be just a missing dependency for the build or something, but threads crash on linux - if (process.platform === "linux") { + if (config.outputMode === "javascript" || process.platform === "linux") { config.useThread = false } ziglib.setUseThread(rendererPtr, config.useThread) @@ -288,12 +295,15 @@ export class CliRenderer extends EventEmitter implements RenderContext { private _splitHeight: number = 0 private renderOffset: number = 0 + private outputStrategy!: OutputStrategy + private outputMode: OutputMode = "native" private _terminalWidth: number = 0 private _terminalHeight: number = 0 private _terminalIsSetup: boolean = false - private realStdoutWrite: (chunk: any, encoding?: any, callback?: any) => boolean + private realStdoutWrite: StdoutWrite + private writeOut: StdoutWrite private captureCallback: () => void = () => { if (this._splitHeight > 0) { this.requestRender() @@ -375,12 +385,17 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.stdin = stdin this.stdout = stdout this.realStdoutWrite = stdout.write + this.writeOut = (chunk, encoding, callback) => this.realStdoutWrite.call(this.stdout, chunk, encoding, callback) this.lib = lib this._terminalWidth = stdout.columns this._terminalHeight = stdout.rows this.width = width this.height = height - this._useThread = config.useThread === undefined ? false : config.useThread + + const outputMode = config.outputMode ?? "native" + this.outputMode = outputMode + const requestedUseThread = config.useThread === undefined ? false : config.useThread + this._useThread = outputMode === "javascript" ? false : requestedUseThread this._splitHeight = config.experimental_splitHeight || 0 if (this._splitHeight > 0) { @@ -391,6 +406,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { } this.rendererPtr = rendererPtr + this.lib.setUseThread(this.rendererPtr, this._useThread) this.exitOnCtrlC = config.exitOnCtrlC === undefined ? true : config.exitOnCtrlC this.resizeDebounceDelay = config.debounceDelay || 100 this.targetFps = config.targetFps || 30 @@ -404,6 +420,21 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.currentRenderBuffer = this.lib.getCurrentBuffer(this.rendererPtr) this.postProcessFns = config.postProcessFns || [] + this.outputStrategy = createOutputStrategy(outputMode, { + stdout, + stdin, + lib, + rendererPtr, + writeToTerminal: this.writeOut, + emitFlush: (event) => this.emit("flush", event), + onDrain: () => { + if (!this._isDestroyed && (this._isRunning || this.immediateRerenderRequested)) { + this.immediateRerenderRequested = false + this.loop() + } + }, + }) + this.root = new RootRenderable(this) if (this.memorySnapshotInterval > 0) { @@ -497,10 +528,6 @@ export class CliRenderer extends EventEmitter implements RenderContext { return caps?.unicode === "wcwidth" ? "wcwidth" : "unicode" } - private writeOut(chunk: any, encoding?: any, callback?: any): boolean { - return this.realStdoutWrite.call(this.stdout, chunk, encoding, callback) - } - public requestRender() { if ( !this.rendering && @@ -691,6 +718,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { private enableMouse(): void { this._useMouse = true this.lib.enableMouse(this.rendererPtr, this.enableMouseMovement) + this.outputStrategy.flush("enable-mouse") } private disableMouse(): void { @@ -698,19 +726,29 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.capturedRenderable = undefined this.mouseParser.reset() this.lib.disableMouse(this.rendererPtr) + this.outputStrategy.flush("disable-mouse") } public enableKittyKeyboard(flags: number = 0b00001): void { this.lib.enableKittyKeyboard(this.rendererPtr, flags) + this.outputStrategy.flush("enable-kitty") } public disableKittyKeyboard(): void { this.lib.disableKittyKeyboard(this.rendererPtr) + this.outputStrategy.flush("disable-kitty") } public set useThread(useThread: boolean) { - this._useThread = useThread - this.lib.setUseThread(this.rendererPtr, useThread) + if (this.outputMode === "javascript" && useThread) { + console.warn("CliRenderer: threaded rendering is not supported while outputMode === 'javascript'") + } + const nextValue = this.outputMode === "javascript" ? false : useThread + if (this._useThread === nextValue) { + return + } + this._useThread = nextValue + this.lib.setUseThread(this.rendererPtr, nextValue) } // TODO:All input management may move to native when zig finally has async io support again, @@ -719,19 +757,8 @@ export class CliRenderer extends EventEmitter implements RenderContext { if (this._terminalIsSetup) return this._terminalIsSetup = true - await new Promise((resolve) => { - const timeout = setTimeout(() => { - this.stdin.off("data", capListener) - resolve(true) - }, 100) - const capListener = (str: string) => { - clearTimeout(timeout) - this.lib.processCapabilityResponse(this.rendererPtr, str) - this.stdin.off("data", capListener) - resolve(true) - } - this.stdin.on("data", capListener) - this.lib.setupTerminal(this.rendererPtr, this._useAlternateScreen) + await this.outputStrategy.setup(this._useAlternateScreen, (str: string) => { + this.lib.processCapabilityResponse(this.rendererPtr, str) }) this._capabilities = this.lib.getTerminalCapabilities(this.rendererPtr) @@ -994,6 +1021,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { private queryPixelResolution() { this.waitingForPixelResolution = true this.lib.queryPixelResolution(this.rendererPtr) + this.outputStrategy.flush("pixel-resolution") } private processResize(width: number, height: number): void { @@ -1070,6 +1098,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { public setTerminalTitle(title: string): void { this.lib.setTerminalTitle(this.rendererPtr, title) + this.outputStrategy.flush("title") } public dumpHitGrid(): void { @@ -1087,6 +1116,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { public static setCursorPosition(renderer: CliRenderer, x: number, y: number, visible: boolean = true): void { const lib = resolveRenderLib() lib.setCursorPosition(renderer.rendererPtr, x, y, visible) + renderer.outputStrategy.flush("cursor-position") } public static setCursorStyle( @@ -1100,15 +1130,18 @@ export class CliRenderer extends EventEmitter implements RenderContext { if (color) { lib.setCursorColor(renderer.rendererPtr, color) } + renderer.outputStrategy.flush("cursor-style") } public static setCursorColor(renderer: CliRenderer, color: RGBA): void { const lib = resolveRenderLib() lib.setCursorColor(renderer.rendererPtr, color) + renderer.outputStrategy.flush("cursor-color") } public setCursorPosition(x: number, y: number, visible: boolean = true): void { this.lib.setCursorPosition(this.rendererPtr, x, y, visible) + this.outputStrategy.flush("cursor-position") } public setCursorStyle(style: CursorStyle, blinking: boolean = false, color?: RGBA): void { @@ -1116,10 +1149,12 @@ export class CliRenderer extends EventEmitter implements RenderContext { if (color) { this.lib.setCursorColor(this.rendererPtr, color) } + this.outputStrategy.flush("cursor-style") } public setCursorColor(color: RGBA): void { this.lib.setCursorColor(this.rendererPtr, color) + this.outputStrategy.flush("cursor-color") } public addPostProcessFn(processFn: (buffer: OptimizedBuffer, deltaTime: number) => void): void { @@ -1289,11 +1324,15 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.flushStdoutCache(this._splitHeight, true) } + this.outputStrategy.destroy() + if (this.stdin.setRawMode) { this.stdin.setRawMode(false) } this.stdin.removeListener("data", this.stdinListener) + this.outputStrategy.teardown() + this.lib.destroyRenderer(this.rendererPtr) rendererTracker.removeRenderer(this) } @@ -1311,7 +1350,9 @@ export class CliRenderer extends EventEmitter implements RenderContext { } private async loop(): Promise { - if (this.rendering || this._isDestroyed) return + if (this.rendering || this._isDestroyed || !this.outputStrategy.canRender()) { + return + } this.rendering = true if (this.renderTimeout) { clearTimeout(this.renderTimeout) @@ -1376,7 +1417,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.collectStatSample(overallFrameTime) } - if (this._isRunning) { + if (this._isRunning && this.outputStrategy.canRender()) { const delay = Math.max(1, this.targetFrameTime - Math.floor(overallFrameTime)) this.renderTimeout = setTimeout(() => this.loop(), delay) } @@ -1407,7 +1448,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { } this.renderingNative = true - this.lib.render(this.rendererPtr, force) + this.outputStrategy.render(force) // this.dumpStdoutBuffer(Date.now()) this.renderingNative = false } diff --git a/packages/core/src/testing/javascript-output-mode.test.ts b/packages/core/src/testing/javascript-output-mode.test.ts new file mode 100644 index 000000000..1b785fe30 --- /dev/null +++ b/packages/core/src/testing/javascript-output-mode.test.ts @@ -0,0 +1,71 @@ +import { describe, test, expect } from "bun:test" +import { PassThrough } from "stream" + +import { CollectingStdout } from "./stdout-mocks" +import { createTestRenderer } from "./test-renderer" + +describe("outputMode: 'javascript'", () => { + test("setup and render flush native buffers", async () => { + const stdout = new CollectingStdout() + const stdin = new PassThrough() + ;(stdin as any).isTTY = true + + const { renderer, renderOnce } = await createTestRenderer({ + outputMode: "javascript", + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + useAlternateScreen: false, + useConsole: false, + exitOnCtrlC: false, + }) + + try { + await renderer.setupTerminal() + expect(stdout.writes.length).toBeGreaterThan(0) + + stdout.clearWrites() + await renderOnce() + expect(stdout.writes.length).toBeGreaterThan(0) + } finally { + renderer.destroy() + stdout.destroy() + stdin.destroy() + } + }) + + test("backpressure pauses rendering until drain", async () => { + const stdout = new CollectingStdout() + const stdin = new PassThrough() + ;(stdin as any).isTTY = true + + const { renderer, renderOnce } = await createTestRenderer({ + outputMode: "javascript", + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + useAlternateScreen: false, + useConsole: false, + exitOnCtrlC: false, + }) + + try { + await renderer.setupTerminal() + stdout.clearWrites() + stdout.forcedBackpressure = true + + await renderOnce() + + expect(stdout.writes.length).toBeGreaterThan(0) + expect((renderer as any).outputStrategy.canRender()).toBe(false) + + stdout.forcedBackpressure = false + stdout.emit("drain") + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect((renderer as any).outputStrategy.canRender()).toBe(true) + } finally { + renderer.destroy() + stdout.destroy() + stdin.destroy() + } + }) +}) diff --git a/packages/core/src/testing/stdout-mocks.ts b/packages/core/src/testing/stdout-mocks.ts new file mode 100644 index 000000000..4fd14b340 --- /dev/null +++ b/packages/core/src/testing/stdout-mocks.ts @@ -0,0 +1,53 @@ +import { EventEmitter } from "events" +import { PassThrough } from "stream" + +export type CapturingStdout = NodeJS.WriteStream & { written: string[] } + +export function createCapturingStdout(): CapturingStdout { + const stdout = new EventEmitter() as CapturingStdout + stdout.columns = 120 + stdout.rows = 40 + stdout.isTTY = true + stdout.writable = true + stdout.writableLength = 0 + stdout.written = [] + stdout.write = function (chunk: any) { + stdout.written.push(chunk.toString()) + return true + } + stdout.cork = () => {} + stdout.uncork = () => {} + stdout.setDefaultEncoding = () => stdout + stdout.end = () => stdout + stdout.destroySoon = () => stdout + stdout.clearLine = () => true + stdout.cursorTo = () => true + stdout.moveCursor = () => true + stdout.getColorDepth = () => 24 + stdout.hasColors = () => true + stdout.getWindowSize = () => [stdout.columns, stdout.rows] + stdout.ref = () => stdout + stdout.unref = () => stdout + return stdout +} + +export class CollectingStdout extends PassThrough { + public writes: Buffer[] = [] + public forcedBackpressure = false + public columns = 80 + public rows = 24 + public isTTY = true + + override write(chunk: any, encoding?: any, callback?: any): boolean { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding) + this.writes.push(Buffer.from(buffer)) + if (typeof callback === "function") { + callback() + } + return !this.forcedBackpressure + } + + clearWrites(): void { + this.writes = [] + } +} diff --git a/packages/core/src/testing/test-renderer.ts b/packages/core/src/testing/test-renderer.ts index 39748325f..11a42388a 100644 --- a/packages/core/src/testing/test-renderer.ts +++ b/packages/core/src/testing/test-renderer.ts @@ -6,6 +6,7 @@ import { createMockMouse } from "./mock-mouse" export interface TestRendererOptions extends CliRendererConfig { width?: number height?: number + disableStdoutInterception?: boolean } export interface TestRenderer extends CliRenderer {} export type MockInput = ReturnType @@ -28,7 +29,9 @@ export async function createTestRenderer(options: TestRendererOptions): Promise< useConsole: false, }) - renderer.disableStdoutInterception() + if (options.disableStdoutInterception ?? true) { + renderer.disableStdoutInterception() + } const mockInput = createMockKeys(renderer) const mockMouse = createMockMouse(renderer) @@ -73,7 +76,7 @@ async function setupTestRenderer(config: TestRendererOptions) { config.useThread = true } - if (process.platform === "linux") { + if (config.outputMode === "javascript" || process.platform === "linux") { config.useThread = false } ziglib.setUseThread(rendererPtr, config.useThread) diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 82426b914..5fc7d9f8c 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -80,6 +80,30 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "bool"], returns: "void", }, + setWriteTarget: { + args: ["ptr", "u32"], + returns: "void", + }, + renderIntoWriteBuffer: { + args: ["ptr", "bool"], + returns: "usize", + }, + getWriteBufferLength: { + args: ["ptr"], + returns: "usize", + }, + copyWriteBuffer: { + args: ["ptr", "ptr", "usize"], + returns: "usize", + }, + setupTerminalToBuffer: { + args: ["ptr", "bool"], + returns: "usize", + }, + teardownTerminalToBuffer: { + args: ["ptr"], + returns: "usize", + }, getNextBuffer: { args: ["ptr"], returns: "ptr", @@ -965,9 +989,15 @@ export interface RenderLib { setUseThread: (renderer: Pointer, useThread: boolean) => void setBackgroundColor: (renderer: Pointer, color: RGBA) => void setRenderOffset: (renderer: Pointer, offset: number) => void + setWriteTarget: (renderer: Pointer, target: number) => void updateStats: (renderer: Pointer, time: number, fps: number, frameCallbackTime: number) => void updateMemoryStats: (renderer: Pointer, heapUsed: number, heapTotal: number, arrayBuffers: number) => void render: (renderer: Pointer, force: boolean) => void + renderIntoWriteBuffer: (renderer: Pointer, force: boolean) => number + getWriteBufferLength: (renderer: Pointer) => number + copyWriteBuffer: (renderer: Pointer, target: Uint8Array) => number + setupTerminalToBuffer: (renderer: Pointer, useAlternateScreen: boolean) => number + teardownTerminalToBuffer: (renderer: Pointer) => number getNextBuffer: (renderer: Pointer) => OptimizedBuffer getCurrentBuffer: (renderer: Pointer) => OptimizedBuffer createOptimizedBuffer: ( @@ -1398,6 +1428,10 @@ class FFIRenderLib implements RenderLib { this.opentui.symbols.setRenderOffset(renderer, offset) } + public setWriteTarget(renderer: Pointer, target: number) { + this.opentui.symbols.setWriteTarget(renderer, target) + } + public updateStats(renderer: Pointer, time: number, fps: number, frameCallbackTime: number) { this.opentui.symbols.updateStats(renderer, time, fps, frameCallbackTime) } @@ -1655,6 +1689,29 @@ class FFIRenderLib implements RenderLib { this.opentui.symbols.render(renderer, force) } + public renderIntoWriteBuffer(renderer: Pointer, force: boolean): number { + return Number(this.opentui.symbols.renderIntoWriteBuffer(renderer, force)) + } + + public getWriteBufferLength(renderer: Pointer): number { + return Number(this.opentui.symbols.getWriteBufferLength(renderer)) + } + + public copyWriteBuffer(renderer: Pointer, target: Uint8Array): number { + if (target.byteLength === 0) { + return 0 + } + return Number(this.opentui.symbols.copyWriteBuffer(renderer, ptr(target), target.byteLength)) + } + + public setupTerminalToBuffer(renderer: Pointer, useAlternateScreen: boolean): number { + return Number(this.opentui.symbols.setupTerminalToBuffer(renderer, useAlternateScreen)) + } + + public teardownTerminalToBuffer(renderer: Pointer): number { + return Number(this.opentui.symbols.teardownTerminalToBuffer(renderer)) + } + public createOptimizedBuffer( width: number, height: number, diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index a7ede9149..928af1b79 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -68,6 +68,35 @@ export fn setRenderOffset(rendererPtr: *renderer.CliRenderer, offset: u32) void rendererPtr.setRenderOffset(offset); } +export fn setWriteTarget(rendererPtr: *renderer.CliRenderer, target: u32) void { + const mode: renderer.WriteTarget = switch (target) { + 0 => .tty, + 1 => .buffer, + else => .tty, + }; + rendererPtr.setWriteTarget(mode); +} + +export fn renderIntoWriteBuffer(rendererPtr: *renderer.CliRenderer, force: bool) usize { + return rendererPtr.renderIntoWriteBuffer(force); +} + +export fn getWriteBufferLength(rendererPtr: *renderer.CliRenderer) usize { + return rendererPtr.getWriteBufferLength(); +} + +export fn copyWriteBuffer(rendererPtr: *renderer.CliRenderer, destPtr: [*]u8, maxLen: usize) usize { + return rendererPtr.copyWriteBuffer(destPtr, maxLen); +} + +export fn setupTerminalToBuffer(rendererPtr: *renderer.CliRenderer, useAlternateScreen: bool) usize { + return rendererPtr.setupTerminalToBuffer(useAlternateScreen); +} + +export fn teardownTerminalToBuffer(rendererPtr: *renderer.CliRenderer) usize { + return rendererPtr.teardownTerminalToBuffer(); +} + export fn updateStats(rendererPtr: *renderer.CliRenderer, time: f64, fps: u32, frameCallbackTime: f64) void { rendererPtr.updateStats(time, fps, frameCallbackTime); } @@ -177,9 +206,7 @@ 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); + rendererPtr.setTerminalTitle(title); } // Buffer functions diff --git a/packages/core/src/zig/renderer.zig b/packages/core/src/zig/renderer.zig index a12b24bff..e0711721e 100644 --- a/packages/core/src/zig/renderer.zig +++ b/packages/core/src/zig/renderer.zig @@ -5,6 +5,7 @@ const buf = @import("buffer.zig"); const gp = @import("grapheme.zig"); const Terminal = @import("terminal.zig"); const logger = @import("logger.zig"); +const AnyWriter = std.io.AnyWriter; pub const RGBA = ansi.RGBA; pub const OptimizedBuffer = buf.OptimizedBuffer; @@ -18,6 +19,57 @@ const STAT_SAMPLE_CAPACITY = 30; const COLOR_EPSILON_DEFAULT: f32 = 0.00001; const OUTPUT_BUFFER_SIZE = 1024 * 1024 * 2; // 2MB +pub const WriteTarget = enum { + tty, + buffer, +}; + +const WriteBuffer = struct { + storage: std.ArrayList(u8), + + pub fn init(allocator: Allocator) WriteBuffer { + return .{ + .storage = std.ArrayList(u8).init(allocator), + }; + } + + pub fn deinit(self: *WriteBuffer) void { + self.storage.deinit(); + } + + pub fn reset(self: *WriteBuffer) void { + self.storage.clearRetainingCapacity(); + } + + pub fn len(self: *WriteBuffer) usize { + return self.storage.items.len; + } + + pub fn writer(self: *WriteBuffer) WriteBufferWriter { + return .{ .context = self }; + } + + pub fn copyTo(self: *WriteBuffer, dest: []u8) usize { + const bytes = self.storage.items; + const copy_len = @min(bytes.len, dest.len); + if (copy_len == 0) return 0; + @memcpy(dest[0..copy_len], bytes[0..copy_len]); + return copy_len; + } + + fn writeFn(self: *WriteBuffer, data: []const u8) error{OutOfMemory}!usize { + try self.storage.appendSlice(data); + return data.len; + } +}; + +const WriteBufferWriter = std.io.Writer(*WriteBuffer, error{OutOfMemory}, WriteBuffer.writeFn); + +const TerminalWriteContext = struct { + writer: AnyWriter, + target: WriteTarget, +}; + pub const RendererError = error{ OutOfMemory, InvalidDimensions, @@ -51,6 +103,8 @@ pub const CliRenderer = struct { testing: bool = false, useAlternateScreen: bool = true, terminalSetup: bool = false, + writeTarget: WriteTarget = .tty, + writeBuffer: WriteBuffer, renderStats: struct { lastFrameTime: f64, @@ -182,6 +236,8 @@ pub const CliRenderer = struct { .renderOffset = 0, .terminal = Terminal.init(.{}), .testing = testing, + .writeTarget = .tty, + .writeBuffer = WriteBuffer.init(allocator), .renderStats = .{ .lastFrameTime = 0, @@ -251,6 +307,7 @@ pub const CliRenderer = struct { self.statSamples.cellsUpdated.deinit(); self.statSamples.frameCallbackTime.deinit(); + self.writeBuffer.deinit(); self.allocator.free(self.currentHitGrid); self.allocator.free(self.nextHitGrid); @@ -261,15 +318,14 @@ pub const CliRenderer = struct { self.useAlternateScreen = useAlternateScreen; self.terminalSetup = true; - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + const ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + const writer = ctx.writer; self.terminal.queryTerminalSend(writer) catch { logger.warn("Failed to query terminal capabilities", .{}); }; - writer.writeAll(ansi.ANSI.saveCursorState) catch {}; - if (useAlternateScreen) { self.terminal.enterAltScreen(writer) catch {}; } else { @@ -277,41 +333,29 @@ pub const CliRenderer = struct { } self.terminal.setCursorPosition(1, 1, false); - - bufferedWriter.flush() catch {}; } pub fn performShutdownSequence(self: *CliRenderer) void { if (!self.terminalSetup) return; - const direct = self.stdoutWriter.writer(); - self.terminal.resetState(direct) catch { + const ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + const writer = ctx.writer; + + self.terminal.resetState(writer) catch { logger.warn("Failed to reset terminal state", .{}); }; - if (self.useAlternateScreen) { - self.stdoutWriter.flush() catch {}; - } else if (self.renderOffset == 0) { - direct.writeAll("\x1b[H\x1b[J") catch {}; - self.stdoutWriter.flush() catch {}; - } else if (self.renderOffset > 0) { - // Currently still handled in typescript - // const consoleEndLine = self.height - self.renderOffset; - // ansi.ANSI.moveToOutput(direct, 1, consoleEndLine) catch {}; + if (!self.useAlternateScreen and self.renderOffset == 0) { + writer.writeAll("\x1b[H\x1b[J") catch {}; } - // NOTE: This messes up state after shutdown, but might be necessary for windows? - // direct.writeAll(ansi.ANSI.restoreCursorState) catch {}; - - direct.writeAll(ansi.ANSI.resetCursorColorFallback) catch {}; - direct.writeAll(ansi.ANSI.resetCursorColor) catch {}; - 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 {}; + writer.writeAll(ansi.ANSI.resetCursorColorFallback) catch {}; + writer.writeAll(ansi.ANSI.resetCursorColor) catch {}; + writer.writeAll(ansi.ANSI.defaultCursorStyle) catch {}; + writer.writeAll(ansi.ANSI.showCursor) catch {}; std.time.sleep(10 * std.time.ns_per_ms); - direct.writeAll(ansi.ANSI.showCursor) catch {}; - self.stdoutWriter.flush() catch {}; + writer.writeAll(ansi.ANSI.showCursor) catch {}; std.time.sleep(10 * std.time.ns_per_ms); } @@ -416,6 +460,75 @@ pub const CliRenderer = struct { self.renderOffset = offset; } + fn beginTerminalWrite(self: *CliRenderer) TerminalWriteContext { + if (self.writeTarget == .buffer) { + self.writeBuffer.reset(); + return .{ .writer = self.writeBuffer.writer().any(), .target = .buffer }; + } + + return .{ .writer = self.stdoutWriter.writer().any(), .target = .tty }; + } + + fn endTerminalWrite(self: *CliRenderer, ctx: TerminalWriteContext) void { + if (ctx.target == .tty) { + self.stdoutWriter.flush() catch {}; + } + } + + pub fn setTerminalTitle(self: *CliRenderer, title: []const u8) void { + const ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + self.terminal.setTerminalTitle(ctx.writer, title); + } + + pub fn setWriteTarget(self: *CliRenderer, target: WriteTarget) void { + if (self.writeTarget == target) return; + self.writeTarget = target; + if (target == .buffer) { + self.writeBuffer.reset(); + } + } + + pub fn getWriteBufferLength(self: *CliRenderer) usize { + return self.writeBuffer.len(); + } + + pub fn copyWriteBuffer(self: *CliRenderer, dest: [*]u8, maxLen: usize) usize { + if (maxLen == 0) return 0; + const bytes = self.writeBuffer.storage.items; + const len = @min(bytes.len, maxLen); + if (len == 0) return 0; + const destSlice = dest[0..len]; + @memcpy(destSlice, bytes[0..len]); + return len; + } + + pub fn renderIntoWriteBuffer(self: *CliRenderer, force: bool) usize { + self.setWriteTarget(.buffer); + self.render(force); + return self.writeBuffer.len(); + } + + pub fn setupTerminalToBuffer(self: *CliRenderer, useAlternateScreen: bool) usize { + self.setWriteTarget(.buffer); + self.setupTerminal(useAlternateScreen); + return self.writeBuffer.len(); + } + + pub fn teardownTerminalToBuffer(self: *CliRenderer) usize { + self.setWriteTarget(.buffer); + self.performShutdownSequence(); + return self.writeBuffer.len(); + } + + + fn storeInWriteBuffer(self: *CliRenderer, data: []const u8) void { + self.writeBuffer.reset(); + self.writeBuffer.storage.appendSlice(data) catch { + logger.warn("Failed to append {d} bytes to write buffer\n", .{data.len}); + }; + } + fn renderThreadFn(self: *CliRenderer) void { while (true) { self.renderMutex.lock(); @@ -435,13 +548,21 @@ pub const CliRenderer = struct { const writeStart = std.time.microTimestamp(); if (outputLen > 0) { - var bufferedWriter = &self.stdoutWriter; - bufferedWriter.writer().writeAll(outputData[0..outputLen]) catch {}; - bufferedWriter.flush() catch {}; + if (self.writeTarget == .buffer) { + self.storeInWriteBuffer(outputData[0..outputLen]); + } else { + var bufferedWriter = &self.stdoutWriter; + bufferedWriter.writer().writeAll(outputData[0..outputLen]) catch {}; + bufferedWriter.flush() catch {}; + } } // Signal that rendering is complete - self.renderStats.stdoutWriteTime = @as(f64, @floatFromInt(std.time.microTimestamp() - writeStart)); + if (self.writeTarget == .buffer) { + self.renderStats.stdoutWriteTime = null; + } else { + self.renderStats.stdoutWriteTime = @as(f64, @floatFromInt(std.time.microTimestamp() - writeStart)); + } self.renderInProgress = false; self.renderCondition.signal(); self.renderMutex.unlock(); @@ -481,10 +602,15 @@ 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 {}; - self.renderStats.stdoutWriteTime = @as(f64, @floatFromInt(std.time.microTimestamp() - writeStart)); + if (self.writeTarget == .buffer) { + self.storeInWriteBuffer(outputBuffer[0..outputBufferLen]); + self.renderStats.stdoutWriteTime = null; + } else { + var bufferedWriter = &self.stdoutWriter; + bufferedWriter.writer().writeAll(outputBuffer[0..outputBufferLen]) catch {}; + bufferedWriter.flush() catch {}; + self.renderStats.stdoutWriteTime = @as(f64, @floatFromInt(std.time.microTimestamp() - writeStart)); + } } self.renderStats.lastFrameTime = deltaTime * 1000.0; @@ -707,9 +833,9 @@ pub const CliRenderer = struct { } pub fn clearTerminal(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - bufferedWriter.writer().writeAll(ansi.ANSI.clearAndHome) catch {}; - bufferedWriter.flush() catch {}; + const ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + ctx.writer.writeAll(ansi.ANSI.clearAndHome) catch {}; } pub fn addToHitGrid(self: *CliRenderer, x: i32, y: i32, width: u32, height: u32, id: u32) void { @@ -846,46 +972,33 @@ 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(); - - self.terminal.setMouseMode(writer, true) catch {}; - - bufferedWriter.flush() catch {}; + const ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + self.terminal.setMouseMode(ctx.writer, true) catch {}; } pub fn queryPixelResolution(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); - - writer.writeAll(ansi.ANSI.queryPixelSize) catch {}; - - bufferedWriter.flush() catch {}; + const ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + ctx.writer.writeAll(ansi.ANSI.queryPixelSize) catch {}; } pub fn disableMouse(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); - - self.terminal.setMouseMode(writer, false) catch {}; - - bufferedWriter.flush() catch {}; + const ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + self.terminal.setMouseMode(ctx.writer, false) catch {}; } pub fn enableKittyKeyboard(self: *CliRenderer, flags: u8) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); - - self.terminal.setKittyKeyboard(writer, true, flags) catch {}; - bufferedWriter.flush() catch {}; + const ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + self.terminal.setKittyKeyboard(ctx.writer, true, flags) catch {}; } pub fn disableKittyKeyboard(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); - - self.terminal.setKittyKeyboard(writer, false, 0) catch {}; - bufferedWriter.flush() catch {}; + const ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + self.terminal.setKittyKeyboard(ctx.writer, false, 0) catch {}; } pub fn getTerminalCapabilities(self: *CliRenderer) Terminal.Capabilities { @@ -894,8 +1007,9 @@ pub const CliRenderer = struct { pub fn processCapabilityResponse(self: *CliRenderer, response: []const u8) void { self.terminal.processCapabilityResponse(response); - const writer = self.stdoutWriter.writer(); - self.terminal.enableDetectedFeatures(writer) catch {}; + const ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + self.terminal.enableDetectedFeatures(ctx.writer) catch {}; } pub fn setCursorPosition(self: *CliRenderer, x: u32, y: u32, visible: bool) void { diff --git a/packages/go/opentui.h b/packages/go/opentui.h index 77f12274b..501ac5a71 100644 --- a/packages/go/opentui.h +++ b/packages/go/opentui.h @@ -31,11 +31,17 @@ void setUseThread(CliRenderer* renderer, bool useThread); void destroyRenderer(CliRenderer* renderer, bool useAlternateScreen, uint32_t splitHeight); void setBackgroundColor(CliRenderer* renderer, const float* color); void setRenderOffset(CliRenderer* renderer, uint32_t offset); +void setWriteTarget(CliRenderer* renderer, uint32_t target); void updateStats(CliRenderer* renderer, double time, uint32_t fps, double frameCallbackTime); void updateMemoryStats(CliRenderer* renderer, uint32_t heapUsed, uint32_t heapTotal, uint32_t arrayBuffers); OptimizedBuffer* getNextBuffer(CliRenderer* renderer); OptimizedBuffer* getCurrentBuffer(CliRenderer* renderer); void render(CliRenderer* renderer, bool force); +size_t renderIntoWriteBuffer(CliRenderer* renderer, bool force); +size_t getWriteBufferLength(CliRenderer* renderer); +size_t copyWriteBuffer(CliRenderer* renderer, uint8_t* dest, size_t maxLen); +size_t setupTerminalToBuffer(CliRenderer* renderer, bool useAlternateScreen); +size_t teardownTerminalToBuffer(CliRenderer* renderer); void resizeRenderer(CliRenderer* renderer, uint32_t width, uint32_t height); void enableMouse(CliRenderer* renderer, bool enableMovement); void disableMouse(CliRenderer* renderer); @@ -117,4 +123,4 @@ void bufferDrawTextBuffer(OptimizedBuffer* buffer, TextBuffer* textBuffer, int32 } #endif -#endif // OPENTUI_H \ No newline at end of file +#endif // OPENTUI_H