From 2ed3cb484b57f5c2dd6bb0299e53b93c1b329edc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 25 Oct 2025 19:01:48 -0400 Subject: [PATCH 1/4] Add jsFlush mode for buffered terminal output with backpressure support --- packages/core/src/renderer.ts | 140 +++++++- packages/core/src/testing/js-flush.test.ts | 90 ++++++ packages/core/src/zig.ts | 57 ++++ packages/core/src/zig/lib.zig | 33 +- packages/core/src/zig/renderer.zig | 358 ++++++++++++++++----- packages/go/opentui.h | 8 +- 6 files changed, 597 insertions(+), 89 deletions(-) create mode 100644 packages/core/src/testing/js-flush.test.ts diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 3e7af95ba..a994a6acf 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -52,6 +52,7 @@ registerEnvVar({ export interface CliRendererConfig { stdin?: NodeJS.ReadStream stdout?: NodeJS.WriteStream + jsFlush?: boolean exitOnCtrlC?: boolean debounceDelay?: number targetFps?: number @@ -70,6 +71,11 @@ export interface CliRendererConfig { backgroundColor?: ColorInput } +enum NativeWriteTarget { + TTY = 0, + BUFFER = 1, +} + export type PixelResolution = { width: number height: number @@ -174,6 +180,10 @@ export async function createCliRenderer(config: CliRendererConfig = {}): Promise if (!rendererPtr) { throw new Error("Failed to create renderer") } + if (config.jsFlush) { + config.useThread = false + } + if (config.useThread === undefined) { config.useThread = true } @@ -288,6 +298,11 @@ export class CliRenderer extends EventEmitter implements RenderContext { private _splitHeight: number = 0 private renderOffset: number = 0 + private jsFlush: boolean = false + private nativeWriteBuffer: Uint8Array = new Uint8Array(0) + private awaitingDrain: boolean = false + private pendingImmediateRerender: boolean = false + private drainListener: (() => void) | null = null private _terminalWidth: number = 0 private _terminalHeight: number = 0 @@ -387,6 +402,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.stdin = stdin this.stdout = stdout + this.jsFlush = Boolean(config.jsFlush) this.realStdoutWrite = stdout.write this.lib = lib this._terminalWidth = stdout.columns @@ -417,13 +433,17 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.currentRenderBuffer = this.lib.getCurrentBuffer(this.rendererPtr) this.postProcessFns = config.postProcessFns || [] + if (this.jsFlush) { + this.lib.setWriteTarget(this.rendererPtr, NativeWriteTarget.BUFFER) + } + this.root = new RootRenderable(this) if (this.memorySnapshotInterval > 0) { this.startMemorySnapshotTimer() } - if (env.OTUI_OVERRIDE_STDOUT) { + if (!this.jsFlush && env.OTUI_OVERRIDE_STDOUT) { this.stdout.write = this.interceptStdoutWrite.bind(this) } @@ -701,9 +721,73 @@ export class CliRenderer extends EventEmitter implements RenderContext { return true } + 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(): Buffer | null { + if (!this.jsFlush) { + return null + } + const length = this.lib.getWriteBufferLength(this.rendererPtr) + if (!length) { + return null + } + this.ensureNativeWriteBufferSize(length) + const copied = this.lib.copyWriteBuffer(this.rendererPtr, this.nativeWriteBuffer) + if (!copied) { + return null + } + const chunk = this.nativeWriteBuffer.subarray(0, copied) + return Buffer.from(chunk) + } + + private flushNativeOutput(reason: string = "frame"): number { + if (!this.jsFlush) { + return 0 + } + const chunk = this.readNativeBuffer() + if (!chunk || chunk.length === 0) { + return 0 + } + const wrote = this.stdout.write(chunk) + this.emit("flush", { bytes: chunk.length, reason }) + if (!wrote) { + this.scheduleDrain() + } + return chunk.length + } + + private scheduleDrain(): void { + if (this.awaitingDrain || typeof this.stdout.once !== "function") { + return + } + this.awaitingDrain = true + this.pendingImmediateRerender = this.pendingImmediateRerender || this.immediateRerenderRequested + this.drainListener = this.handleDrain + this.stdout.once("drain", this.handleDrain) + } + + private handleDrain = (): void => { + this.awaitingDrain = false + if (this.drainListener) { + this.drainListener = null + } + const shouldLoop = this._isRunning || this.pendingImmediateRerender + this.pendingImmediateRerender = false + if (!this._isDestroyed && shouldLoop) { + this.loop() + } + } + private enableMouse(): void { this._useMouse = true this.lib.enableMouse(this.rendererPtr, this.enableMouseMovement) + this.flushNativeOutput("enable-mouse") } private disableMouse(): void { @@ -711,14 +795,17 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.capturedRenderable = undefined this.mouseParser.reset() this.lib.disableMouse(this.rendererPtr) + this.flushNativeOutput("disable-mouse") } public enableKittyKeyboard(flags: number = 0b00001): void { this.lib.enableKittyKeyboard(this.rendererPtr, flags) + this.flushNativeOutput("enable-kitty") } public disableKittyKeyboard(): void { this.lib.disableKittyKeyboard(this.rendererPtr) + this.flushNativeOutput("disable-kitty") } public set useThread(useThread: boolean) { @@ -740,11 +827,17 @@ export class CliRenderer extends EventEmitter implements RenderContext { const capListener = (str: string) => { clearTimeout(timeout) this.lib.processCapabilityResponse(this.rendererPtr, str) + this.flushNativeOutput("capabilities") this.stdin.off("data", capListener) resolve(true) } this.stdin.on("data", capListener) - this.lib.setupTerminal(this.rendererPtr, this._useAlternateScreen) + if (this.jsFlush) { + this.lib.setupTerminalToBuffer(this.rendererPtr, this._useAlternateScreen) + this.flushNativeOutput("setup") + } else { + this.lib.setupTerminal(this.rendererPtr, this._useAlternateScreen) + } }) this._capabilities = this.lib.getTerminalCapabilities(this.rendererPtr) @@ -1007,6 +1100,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { private queryPixelResolution() { this.waitingForPixelResolution = true this.lib.queryPixelResolution(this.rendererPtr) + this.flushNativeOutput("pixel-resolution") } private processResize(width: number, height: number): void { @@ -1083,6 +1177,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { public setTerminalTitle(title: string): void { this.lib.setTerminalTitle(this.rendererPtr, title) + this.flushNativeOutput("title") } public dumpHitGrid(): void { @@ -1100,6 +1195,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.flushNativeOutput("cursor-position") } public static setCursorStyle( @@ -1113,15 +1209,18 @@ export class CliRenderer extends EventEmitter implements RenderContext { if (color) { lib.setCursorColor(renderer.rendererPtr, color) } + renderer.flushNativeOutput("cursor-style") } public static setCursorColor(renderer: CliRenderer, color: RGBA): void { const lib = resolveRenderLib() lib.setCursorColor(renderer.rendererPtr, color) + renderer.flushNativeOutput("cursor-color") } public setCursorPosition(x: number, y: number, visible: boolean = true): void { this.lib.setCursorPosition(this.rendererPtr, x, y, visible) + this.flushNativeOutput("cursor-position") } public setCursorStyle(style: CursorStyle, blinking: boolean = false, color?: RGBA): void { @@ -1129,6 +1228,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { if (color) { this.lib.setCursorColor(this.rendererPtr, color) } + this.flushNativeOutput("cursor-style") } public setCursorColor(color: RGBA): void { @@ -1302,11 +1402,22 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.flushStdoutCache(this._splitHeight, true) } + if (this.awaitingDrain && this.drainListener && typeof this.stdout.off === "function") { + this.stdout.off("drain", this.drainListener) + this.drainListener = null + this.awaitingDrain = false + } + if (this.stdin.setRawMode) { this.stdin.setRawMode(false) } this.stdin.removeListener("data", this.stdinListener) + if (this.jsFlush) { + this.lib.teardownTerminalToBuffer(this.rendererPtr) + this.flushNativeOutput("teardown") + } + this.lib.destroyRenderer(this.rendererPtr) rendererTracker.removeRenderer(this) } @@ -1324,7 +1435,13 @@ export class CliRenderer extends EventEmitter implements RenderContext { } private async loop(): Promise { - if (this.rendering || this._isDestroyed) return + if (this.rendering || this._isDestroyed || this.awaitingDrain) { + if (this.awaitingDrain && this.immediateRerenderRequested) { + this.pendingImmediateRerender = true + this.immediateRerenderRequested = false + } + return + } this.rendering = true if (this.renderTimeout) { clearTimeout(this.renderTimeout) @@ -1389,15 +1506,21 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.collectStatSample(overallFrameTime) } - if (this._isRunning) { + if (this._isRunning && !this.awaitingDrain) { const delay = Math.max(1, this.targetFrameTime - Math.floor(overallFrameTime)) this.renderTimeout = setTimeout(() => this.loop(), delay) + } else if (this.awaitingDrain) { + this.pendingImmediateRerender = this.pendingImmediateRerender || this.immediateRerenderRequested } } this.rendering = false if (this.immediateRerenderRequested) { this.immediateRerenderRequested = false - this.loop() + if (this.awaitingDrain) { + this.pendingImmediateRerender = true + } else { + this.loop() + } } } @@ -1420,7 +1543,12 @@ export class CliRenderer extends EventEmitter implements RenderContext { } this.renderingNative = true - this.lib.render(this.rendererPtr, force) + if (this.jsFlush) { + this.lib.renderIntoWriteBuffer(this.rendererPtr, force) + this.flushNativeOutput("frame") + } else { + this.lib.render(this.rendererPtr, force) + } // this.dumpStdoutBuffer(Date.now()) this.renderingNative = false } diff --git a/packages/core/src/testing/js-flush.test.ts b/packages/core/src/testing/js-flush.test.ts new file mode 100644 index 000000000..39b0af265 --- /dev/null +++ b/packages/core/src/testing/js-flush.test.ts @@ -0,0 +1,90 @@ +import { describe, test, expect } from "bun:test" +import { PassThrough } from "stream" +import { createTestRenderer } from "./test-renderer" + +class CollectingStream extends PassThrough { + public writes: Buffer[] = [] + public forcedBackpressure = false + public columns = 80 + public rows = 24 + public isTTY = true + + 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 = [] + } +} + +describe("jsFlush mode", () => { + test("setup and render flush native buffers", async () => { + const stdout = new CollectingStream() + const stdin = new PassThrough() + ;(stdin as any).isTTY = true + + const { renderer, renderOnce } = await createTestRenderer({ + jsFlush: true, + 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 CollectingStream() + const stdin = new PassThrough() + ;(stdin as any).isTTY = true + + const { renderer, renderOnce } = await createTestRenderer({ + jsFlush: true, + 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).awaitingDrain).toBe(true) + + stdout.forcedBackpressure = false + stdout.emit("drain") + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect((renderer as any).awaitingDrain).toBe(false) + } finally { + renderer.destroy() + stdout.destroy() + stdin.destroy() + } + }) +}) diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 76ecd6334..270b8fd73 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", @@ -939,9 +963,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: ( @@ -1364,6 +1394,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) } @@ -1621,6 +1655,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 ea3465b0b..5a85e92cc 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..a608a34c1 100644 --- a/packages/core/src/zig/renderer.zig +++ b/packages/core/src/zig/renderer.zig @@ -18,6 +18,53 @@ 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 AnyWriter = std.io.AnyWriter; + pub const RendererError = error{ OutOfMemory, InvalidDimensions, @@ -51,6 +98,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 +231,8 @@ pub const CliRenderer = struct { .renderOffset = 0, .terminal = Terminal.init(.{}), .testing = testing, + .writeTarget = .tty, + .writeBuffer = WriteBuffer.init(allocator), .renderStats = .{ .lastFrameTime = 0, @@ -251,6 +302,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,58 +313,87 @@ pub const CliRenderer = struct { self.useAlternateScreen = useAlternateScreen; self.terminalSetup = true; - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); - - self.terminal.queryTerminalSend(writer) catch { - logger.warn("Failed to query terminal capabilities", .{}); - }; - - writer.writeAll(ansi.ANSI.saveCursorState) catch {}; + if (self.writeTarget == .buffer) { + self.writeBuffer.reset(); + const buf_writer = self.writeBuffer.writer(); + const writer = buf_writer.any(); - if (useAlternateScreen) { - self.terminal.enterAltScreen(writer) catch {}; + 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 { + ansi.ANSI.makeRoomForRendererOutput(writer, @max(self.height, 1)) catch {}; + } } else { - ansi.ANSI.makeRoomForRendererOutput(writer, @max(self.height, 1)) catch {}; + const stdout_writer = self.stdoutWriter.writer(); + const writer = stdout_writer.any(); + + 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 { + ansi.ANSI.makeRoomForRendererOutput(writer, @max(self.height, 1)) catch {}; + } + self.stdoutWriter.flush() catch {}; } 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 { - logger.warn("Failed to reset terminal state", .{}); - }; + if (self.writeTarget == .buffer) { + self.writeBuffer.reset(); + const buf_writer = self.writeBuffer.writer(); + const writer = buf_writer.any(); + + self.terminal.resetState(writer) catch { + logger.warn("Failed to reset terminal state", .{}); + }; - if (self.useAlternateScreen) { + if (!self.useAlternateScreen and self.renderOffset == 0) { + writer.writeAll("\x1b[H\x1b[J") 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); + writer.writeAll(ansi.ANSI.showCursor) catch {}; + std.time.sleep(10 * std.time.ns_per_ms); + } else { + const stdout_writer = self.stdoutWriter.writer(); + const writer = stdout_writer.any(); + + self.terminal.resetState(writer) catch { + logger.warn("Failed to reset terminal state", .{}); + }; + + if (self.useAlternateScreen) { + self.stdoutWriter.flush() catch {}; + } else if (self.renderOffset == 0) { + writer.writeAll("\x1b[H\x1b[J") 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 {}; self.stdoutWriter.flush() catch {}; - } else if (self.renderOffset == 0) { - direct.writeAll("\x1b[H\x1b[J") catch {}; + std.time.sleep(10 * std.time.ns_per_ms); + writer.writeAll(ansi.ANSI.showCursor) 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 {}; + std.time.sleep(10 * std.time.ns_per_ms); } - - // 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 {}; - std.time.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); } fn addStatSample(comptime T: type, samples: *std.ArrayList(T), value: T) void { @@ -416,6 +497,68 @@ pub const CliRenderer = struct { self.renderOffset = offset; } + pub fn setTerminalTitle(self: *CliRenderer, title: []const u8) void { + if (self.writeTarget == .buffer) { + self.writeBuffer.reset(); + const buf_writer = self.writeBuffer.writer(); + const writer = buf_writer.any(); + self.terminal.setTerminalTitle(writer, title); + } else { + const stdout_writer = self.stdoutWriter.writer(); + const writer = stdout_writer.any(); + self.terminal.setTerminalTitle(writer, title); + self.stdoutWriter.flush() catch {}; + } + } + + 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 +578,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 +632,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 +863,17 @@ pub const CliRenderer = struct { } pub fn clearTerminal(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - bufferedWriter.writer().writeAll(ansi.ANSI.clearAndHome) catch {}; - bufferedWriter.flush() catch {}; + if (self.writeTarget == .buffer) { + self.writeBuffer.reset(); + const buf_writer = self.writeBuffer.writer(); + const writer = buf_writer.any(); + writer.writeAll(ansi.ANSI.clearAndHome) catch {}; + } else { + const stdout_writer = self.stdoutWriter.writer(); + const writer = stdout_writer.any(); + writer.writeAll(ansi.ANSI.clearAndHome) catch {}; + self.stdoutWriter.flush() catch {}; + } } pub fn addToHitGrid(self: *CliRenderer, x: i32, y: i32, width: u32, height: u32, id: u32) void { @@ -846,46 +1010,73 @@ 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 {}; + if (self.writeTarget == .buffer) { + self.writeBuffer.reset(); + const buf_writer = self.writeBuffer.writer(); + const writer = buf_writer.any(); + self.terminal.setMouseMode(writer, true) catch {}; + } else { + const stdout_writer = self.stdoutWriter.writer(); + const writer = stdout_writer.any(); + self.terminal.setMouseMode(writer, true) catch {}; + self.stdoutWriter.flush() catch {}; + } } pub fn queryPixelResolution(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); - - writer.writeAll(ansi.ANSI.queryPixelSize) catch {}; - - bufferedWriter.flush() catch {}; + if (self.writeTarget == .buffer) { + self.writeBuffer.reset(); + const buf_writer = self.writeBuffer.writer(); + const writer = buf_writer.any(); + writer.writeAll(ansi.ANSI.queryPixelSize) catch {}; + } else { + const stdout_writer = self.stdoutWriter.writer(); + const writer = stdout_writer.any(); + writer.writeAll(ansi.ANSI.queryPixelSize) catch {}; + self.stdoutWriter.flush() catch {}; + } } pub fn disableMouse(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); - - self.terminal.setMouseMode(writer, false) catch {}; - - bufferedWriter.flush() catch {}; + if (self.writeTarget == .buffer) { + self.writeBuffer.reset(); + const buf_writer = self.writeBuffer.writer(); + const writer = buf_writer.any(); + self.terminal.setMouseMode(writer, false) catch {}; + } else { + const stdout_writer = self.stdoutWriter.writer(); + const writer = stdout_writer.any(); + self.terminal.setMouseMode(writer, false) catch {}; + self.stdoutWriter.flush() 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 {}; + if (self.writeTarget == .buffer) { + self.writeBuffer.reset(); + const buf_writer = self.writeBuffer.writer(); + const writer = buf_writer.any(); + self.terminal.setKittyKeyboard(writer, true, flags) catch {}; + } else { + const stdout_writer = self.stdoutWriter.writer(); + const writer = stdout_writer.any(); + self.terminal.setKittyKeyboard(writer, true, flags) catch {}; + self.stdoutWriter.flush() 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 {}; + if (self.writeTarget == .buffer) { + self.writeBuffer.reset(); + const buf_writer = self.writeBuffer.writer(); + const writer = buf_writer.any(); + self.terminal.setKittyKeyboard(writer, false, 0) catch {}; + } else { + const stdout_writer = self.stdoutWriter.writer(); + const writer = stdout_writer.any(); + self.terminal.setKittyKeyboard(writer, false, 0) catch {}; + self.stdoutWriter.flush() catch {}; + } } pub fn getTerminalCapabilities(self: *CliRenderer) Terminal.Capabilities { @@ -894,8 +1085,17 @@ 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 {}; + if (self.writeTarget == .buffer) { + self.writeBuffer.reset(); + const buf_writer = self.writeBuffer.writer(); + const writer = buf_writer.any(); + self.terminal.enableDetectedFeatures(writer) catch {}; + } else { + const stdout_writer = self.stdoutWriter.writer(); + const writer = stdout_writer.any(); + self.terminal.enableDetectedFeatures(writer) catch {}; + self.stdoutWriter.flush() 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 From 5628e151590860f943534d8e5568e2d6c314d564 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 25 Oct 2025 19:44:36 -0400 Subject: [PATCH 2/4] simpilfy with TerminalWriteContext abstraction --- packages/core/src/renderer.ts | 7 +- packages/core/src/testing/test-renderer.ts | 13 +- packages/core/src/zig/renderer.zig | 222 +++++++-------------- 3 files changed, 82 insertions(+), 160 deletions(-) diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index a994a6acf..b874136d3 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -409,7 +409,8 @@ export class CliRenderer extends EventEmitter implements RenderContext { this._terminalHeight = stdout.rows this.width = width this.height = height - this._useThread = config.useThread === undefined ? false : config.useThread + const requestedUseThread = config.useThread === undefined ? false : config.useThread + this._useThread = this.jsFlush ? false : requestedUseThread this._splitHeight = config.experimental_splitHeight || 0 if (this._splitHeight > 0) { @@ -420,6 +421,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 @@ -809,6 +811,9 @@ export class CliRenderer extends EventEmitter implements RenderContext { } public set useThread(useThread: boolean) { + if (this.jsFlush && useThread) { + throw new Error("jsFlush mode requires useThread=false") + } this._useThread = useThread this.lib.setUseThread(this.rendererPtr, useThread) } diff --git a/packages/core/src/testing/test-renderer.ts b/packages/core/src/testing/test-renderer.ts index 39748325f..aa5962a1e 100644 --- a/packages/core/src/testing/test-renderer.ts +++ b/packages/core/src/testing/test-renderer.ts @@ -69,12 +69,15 @@ async function setupTestRenderer(config: TestRendererOptions) { if (!rendererPtr) { throw new Error("Failed to create test renderer") } - if (config.useThread === undefined) { - config.useThread = true - } - - if (process.platform === "linux") { + if (config.jsFlush) { config.useThread = false + } else { + if (config.useThread === undefined) { + config.useThread = true + } + if (process.platform === "linux") { + config.useThread = false + } } ziglib.setUseThread(rendererPtr, config.useThread) diff --git a/packages/core/src/zig/renderer.zig b/packages/core/src/zig/renderer.zig index a608a34c1..25ae916d8 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; @@ -63,7 +64,11 @@ const WriteBuffer = struct { }; const WriteBufferWriter = std.io.Writer(*WriteBuffer, error{OutOfMemory}, WriteBuffer.writeFn); -const AnyWriter = std.io.AnyWriter; + +const TerminalWriteContext = struct { + writer: AnyWriter, + target: WriteTarget, +}; pub const RendererError = error{ OutOfMemory, @@ -313,34 +318,18 @@ pub const CliRenderer = struct { self.useAlternateScreen = useAlternateScreen; self.terminalSetup = true; - if (self.writeTarget == .buffer) { - self.writeBuffer.reset(); - const buf_writer = self.writeBuffer.writer(); - const writer = buf_writer.any(); + var 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 { - ansi.ANSI.makeRoomForRendererOutput(writer, @max(self.height, 1)) catch {}; - } + 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 { - const stdout_writer = self.stdoutWriter.writer(); - const writer = stdout_writer.any(); - - 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 { - ansi.ANSI.makeRoomForRendererOutput(writer, @max(self.height, 1)) catch {}; - } - self.stdoutWriter.flush() catch {}; + ansi.ANSI.makeRoomForRendererOutput(writer, @max(self.height, 1)) catch {}; } self.terminal.setCursorPosition(1, 1, false); @@ -349,51 +338,25 @@ pub const CliRenderer = struct { pub fn performShutdownSequence(self: *CliRenderer) void { if (!self.terminalSetup) return; - if (self.writeTarget == .buffer) { - self.writeBuffer.reset(); - const buf_writer = self.writeBuffer.writer(); - const writer = buf_writer.any(); + var 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 and self.renderOffset == 0) { - writer.writeAll("\x1b[H\x1b[J") 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); - writer.writeAll(ansi.ANSI.showCursor) catch {}; - std.time.sleep(10 * std.time.ns_per_ms); - } else { - const stdout_writer = self.stdoutWriter.writer(); - const writer = stdout_writer.any(); - - self.terminal.resetState(writer) catch { - logger.warn("Failed to reset terminal state", .{}); - }; - - if (self.useAlternateScreen) { - self.stdoutWriter.flush() catch {}; - } else if (self.renderOffset == 0) { - writer.writeAll("\x1b[H\x1b[J") catch {}; - self.stdoutWriter.flush() catch {}; - } + self.terminal.resetState(writer) catch { + logger.warn("Failed to reset terminal state", .{}); + }; - writer.writeAll(ansi.ANSI.resetCursorColorFallback) catch {}; - writer.writeAll(ansi.ANSI.resetCursorColor) catch {}; - writer.writeAll(ansi.ANSI.defaultCursorStyle) catch {}; - writer.writeAll(ansi.ANSI.showCursor) catch {}; - self.stdoutWriter.flush() catch {}; - std.time.sleep(10 * std.time.ns_per_ms); - writer.writeAll(ansi.ANSI.showCursor) catch {}; - self.stdoutWriter.flush() catch {}; - std.time.sleep(10 * std.time.ns_per_ms); + if (!self.useAlternateScreen and self.renderOffset == 0) { + writer.writeAll("\x1b[H\x1b[J") 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); + writer.writeAll(ansi.ANSI.showCursor) catch {}; + std.time.sleep(10 * std.time.ns_per_ms); } fn addStatSample(comptime T: type, samples: *std.ArrayList(T), value: T) void { @@ -497,20 +460,27 @@ pub const CliRenderer = struct { self.renderOffset = offset; } - pub fn setTerminalTitle(self: *CliRenderer, title: []const u8) void { + fn beginTerminalWrite(self: *CliRenderer) TerminalWriteContext { if (self.writeTarget == .buffer) { self.writeBuffer.reset(); - const buf_writer = self.writeBuffer.writer(); - const writer = buf_writer.any(); - self.terminal.setTerminalTitle(writer, title); - } else { - const stdout_writer = self.stdoutWriter.writer(); - const writer = stdout_writer.any(); - self.terminal.setTerminalTitle(writer, title); + 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 { + var 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; @@ -863,17 +833,9 @@ pub const CliRenderer = struct { } pub fn clearTerminal(self: *CliRenderer) void { - if (self.writeTarget == .buffer) { - self.writeBuffer.reset(); - const buf_writer = self.writeBuffer.writer(); - const writer = buf_writer.any(); - writer.writeAll(ansi.ANSI.clearAndHome) catch {}; - } else { - const stdout_writer = self.stdoutWriter.writer(); - const writer = stdout_writer.any(); - writer.writeAll(ansi.ANSI.clearAndHome) catch {}; - self.stdoutWriter.flush() catch {}; - } + var 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 { @@ -1010,73 +972,33 @@ pub const CliRenderer = struct { pub fn enableMouse(self: *CliRenderer, enableMovement: bool) void { _ = enableMovement; // TODO: Use this to control motion tracking levels - if (self.writeTarget == .buffer) { - self.writeBuffer.reset(); - const buf_writer = self.writeBuffer.writer(); - const writer = buf_writer.any(); - self.terminal.setMouseMode(writer, true) catch {}; - } else { - const stdout_writer = self.stdoutWriter.writer(); - const writer = stdout_writer.any(); - self.terminal.setMouseMode(writer, true) catch {}; - self.stdoutWriter.flush() catch {}; - } + var ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + self.terminal.setMouseMode(ctx.writer, true) catch {}; } pub fn queryPixelResolution(self: *CliRenderer) void { - if (self.writeTarget == .buffer) { - self.writeBuffer.reset(); - const buf_writer = self.writeBuffer.writer(); - const writer = buf_writer.any(); - writer.writeAll(ansi.ANSI.queryPixelSize) catch {}; - } else { - const stdout_writer = self.stdoutWriter.writer(); - const writer = stdout_writer.any(); - writer.writeAll(ansi.ANSI.queryPixelSize) catch {}; - self.stdoutWriter.flush() catch {}; - } + var ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + ctx.writer.writeAll(ansi.ANSI.queryPixelSize) catch {}; } pub fn disableMouse(self: *CliRenderer) void { - if (self.writeTarget == .buffer) { - self.writeBuffer.reset(); - const buf_writer = self.writeBuffer.writer(); - const writer = buf_writer.any(); - self.terminal.setMouseMode(writer, false) catch {}; - } else { - const stdout_writer = self.stdoutWriter.writer(); - const writer = stdout_writer.any(); - self.terminal.setMouseMode(writer, false) catch {}; - self.stdoutWriter.flush() catch {}; - } + var ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + self.terminal.setMouseMode(ctx.writer, false) catch {}; } pub fn enableKittyKeyboard(self: *CliRenderer, flags: u8) void { - if (self.writeTarget == .buffer) { - self.writeBuffer.reset(); - const buf_writer = self.writeBuffer.writer(); - const writer = buf_writer.any(); - self.terminal.setKittyKeyboard(writer, true, flags) catch {}; - } else { - const stdout_writer = self.stdoutWriter.writer(); - const writer = stdout_writer.any(); - self.terminal.setKittyKeyboard(writer, true, flags) catch {}; - self.stdoutWriter.flush() catch {}; - } + var ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + self.terminal.setKittyKeyboard(ctx.writer, true, flags) catch {}; } pub fn disableKittyKeyboard(self: *CliRenderer) void { - if (self.writeTarget == .buffer) { - self.writeBuffer.reset(); - const buf_writer = self.writeBuffer.writer(); - const writer = buf_writer.any(); - self.terminal.setKittyKeyboard(writer, false, 0) catch {}; - } else { - const stdout_writer = self.stdoutWriter.writer(); - const writer = stdout_writer.any(); - self.terminal.setKittyKeyboard(writer, false, 0) catch {}; - self.stdoutWriter.flush() catch {}; - } + var ctx = self.beginTerminalWrite(); + defer self.endTerminalWrite(ctx); + self.terminal.setKittyKeyboard(ctx.writer, false, 0) catch {}; } pub fn getTerminalCapabilities(self: *CliRenderer) Terminal.Capabilities { @@ -1085,17 +1007,9 @@ pub const CliRenderer = struct { pub fn processCapabilityResponse(self: *CliRenderer, response: []const u8) void { self.terminal.processCapabilityResponse(response); - if (self.writeTarget == .buffer) { - self.writeBuffer.reset(); - const buf_writer = self.writeBuffer.writer(); - const writer = buf_writer.any(); - self.terminal.enableDetectedFeatures(writer) catch {}; - } else { - const stdout_writer = self.stdoutWriter.writer(); - const writer = stdout_writer.any(); - self.terminal.enableDetectedFeatures(writer) catch {}; - self.stdoutWriter.flush() catch {}; - } + var 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 { From b0cd5ae52854ce95f1f068aa089b4641d04484e0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 25 Oct 2025 21:55:24 -0400 Subject: [PATCH 3/4] implement OutputStrategy --- packages/core/src/output-strategy.ts | 206 +++++++++++++++++++++ packages/core/src/renderer.ts | 182 ++++-------------- packages/core/src/testing/js-flush.test.ts | 10 +- 3 files changed, 251 insertions(+), 147 deletions(-) create mode 100644 packages/core/src/output-strategy.ts diff --git a/packages/core/src/output-strategy.ts b/packages/core/src/output-strategy.ts new file mode 100644 index 000000000..165b4a3a4 --- /dev/null +++ b/packages/core/src/output-strategy.ts @@ -0,0 +1,206 @@ +import type { Pointer } from "bun:ffi"; +import type { RenderLib } from "./zig"; + +enum NativeWriteTarget { + TTY = 0, + BUFFER = 1, +} + +export interface OutputStrategyOptions { + stdout: NodeJS.WriteStream; + stdin: NodeJS.ReadStream; + lib: RenderLib; + rendererPtr: Pointer; + 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.stdout.write(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(): Buffer | 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; + } + const chunk = this.nativeWriteBuffer.subarray(0, copied); + return Buffer.from(chunk); + } + + 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.ts b/packages/core/src/renderer.ts index b874136d3..aeb609044 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -20,6 +20,7 @@ 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 } from "./output-strategy" registerEnvVar({ name: "OTUI_DUMP_CAPTURES", @@ -52,7 +53,7 @@ registerEnvVar({ export interface CliRendererConfig { stdin?: NodeJS.ReadStream stdout?: NodeJS.WriteStream - jsFlush?: boolean + outputMode?: OutputMode exitOnCtrlC?: boolean debounceDelay?: number targetFps?: number @@ -71,11 +72,6 @@ export interface CliRendererConfig { backgroundColor?: ColorInput } -enum NativeWriteTarget { - TTY = 0, - BUFFER = 1, -} - export type PixelResolution = { width: number height: number @@ -180,7 +176,7 @@ export async function createCliRenderer(config: CliRendererConfig = {}): Promise if (!rendererPtr) { throw new Error("Failed to create renderer") } - if (config.jsFlush) { + if (config.outputMode === 'javascript') { config.useThread = false } @@ -298,11 +294,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { private _splitHeight: number = 0 private renderOffset: number = 0 - private jsFlush: boolean = false - private nativeWriteBuffer: Uint8Array = new Uint8Array(0) - private awaitingDrain: boolean = false - private pendingImmediateRerender: boolean = false - private drainListener: (() => void) | null = null + private outputStrategy!: OutputStrategy private _terminalWidth: number = 0 private _terminalHeight: number = 0 @@ -402,15 +394,16 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.stdin = stdin this.stdout = stdout - this.jsFlush = Boolean(config.jsFlush) this.realStdoutWrite = stdout.write this.lib = lib this._terminalWidth = stdout.columns this._terminalHeight = stdout.rows this.width = width this.height = height + + const outputMode = config.outputMode ?? 'native' const requestedUseThread = config.useThread === undefined ? false : config.useThread - this._useThread = this.jsFlush ? false : requestedUseThread + this._useThread = outputMode === 'javascript' ? false : requestedUseThread this._splitHeight = config.experimental_splitHeight || 0 if (this._splitHeight > 0) { @@ -435,9 +428,19 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.currentRenderBuffer = this.lib.getCurrentBuffer(this.rendererPtr) this.postProcessFns = config.postProcessFns || [] - if (this.jsFlush) { - this.lib.setWriteTarget(this.rendererPtr, NativeWriteTarget.BUFFER) - } + this.outputStrategy = createOutputStrategy(outputMode, { + stdout, + stdin, + lib, + rendererPtr, + emitFlush: (event) => this.emit("flush", event), + onDrain: () => { + if (!this._isDestroyed && (this._isRunning || this.immediateRerenderRequested)) { + this.immediateRerenderRequested = false + this.loop() + } + }, + }) this.root = new RootRenderable(this) @@ -445,7 +448,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.startMemorySnapshotTimer() } - if (!this.jsFlush && env.OTUI_OVERRIDE_STDOUT) { + if (outputMode === 'native' && env.OTUI_OVERRIDE_STDOUT) { this.stdout.write = this.interceptStdoutWrite.bind(this) } @@ -723,73 +726,10 @@ export class CliRenderer extends EventEmitter implements RenderContext { return true } - 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(): Buffer | null { - if (!this.jsFlush) { - return null - } - const length = this.lib.getWriteBufferLength(this.rendererPtr) - if (!length) { - return null - } - this.ensureNativeWriteBufferSize(length) - const copied = this.lib.copyWriteBuffer(this.rendererPtr, this.nativeWriteBuffer) - if (!copied) { - return null - } - const chunk = this.nativeWriteBuffer.subarray(0, copied) - return Buffer.from(chunk) - } - - private flushNativeOutput(reason: string = "frame"): number { - if (!this.jsFlush) { - return 0 - } - const chunk = this.readNativeBuffer() - if (!chunk || chunk.length === 0) { - return 0 - } - const wrote = this.stdout.write(chunk) - this.emit("flush", { bytes: chunk.length, reason }) - if (!wrote) { - this.scheduleDrain() - } - return chunk.length - } - - private scheduleDrain(): void { - if (this.awaitingDrain || typeof this.stdout.once !== "function") { - return - } - this.awaitingDrain = true - this.pendingImmediateRerender = this.pendingImmediateRerender || this.immediateRerenderRequested - this.drainListener = this.handleDrain - this.stdout.once("drain", this.handleDrain) - } - - private handleDrain = (): void => { - this.awaitingDrain = false - if (this.drainListener) { - this.drainListener = null - } - const shouldLoop = this._isRunning || this.pendingImmediateRerender - this.pendingImmediateRerender = false - if (!this._isDestroyed && shouldLoop) { - this.loop() - } - } - private enableMouse(): void { this._useMouse = true this.lib.enableMouse(this.rendererPtr, this.enableMouseMovement) - this.flushNativeOutput("enable-mouse") + this.outputStrategy.flush("enable-mouse") } private disableMouse(): void { @@ -797,23 +737,20 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.capturedRenderable = undefined this.mouseParser.reset() this.lib.disableMouse(this.rendererPtr) - this.flushNativeOutput("disable-mouse") + this.outputStrategy.flush("disable-mouse") } public enableKittyKeyboard(flags: number = 0b00001): void { this.lib.enableKittyKeyboard(this.rendererPtr, flags) - this.flushNativeOutput("enable-kitty") + this.outputStrategy.flush("enable-kitty") } public disableKittyKeyboard(): void { this.lib.disableKittyKeyboard(this.rendererPtr) - this.flushNativeOutput("disable-kitty") + this.outputStrategy.flush("disable-kitty") } public set useThread(useThread: boolean) { - if (this.jsFlush && useThread) { - throw new Error("jsFlush mode requires useThread=false") - } this._useThread = useThread this.lib.setUseThread(this.rendererPtr, useThread) } @@ -824,25 +761,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.flushNativeOutput("capabilities") - this.stdin.off("data", capListener) - resolve(true) - } - this.stdin.on("data", capListener) - if (this.jsFlush) { - this.lib.setupTerminalToBuffer(this.rendererPtr, this._useAlternateScreen) - this.flushNativeOutput("setup") - } else { - 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) @@ -1105,7 +1025,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { private queryPixelResolution() { this.waitingForPixelResolution = true this.lib.queryPixelResolution(this.rendererPtr) - this.flushNativeOutput("pixel-resolution") + this.outputStrategy.flush("pixel-resolution") } private processResize(width: number, height: number): void { @@ -1182,7 +1102,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { public setTerminalTitle(title: string): void { this.lib.setTerminalTitle(this.rendererPtr, title) - this.flushNativeOutput("title") + this.outputStrategy.flush("title") } public dumpHitGrid(): void { @@ -1200,7 +1120,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.flushNativeOutput("cursor-position") + renderer.outputStrategy.flush("cursor-position") } public static setCursorStyle( @@ -1214,18 +1134,18 @@ export class CliRenderer extends EventEmitter implements RenderContext { if (color) { lib.setCursorColor(renderer.rendererPtr, color) } - renderer.flushNativeOutput("cursor-style") + renderer.outputStrategy.flush("cursor-style") } public static setCursorColor(renderer: CliRenderer, color: RGBA): void { const lib = resolveRenderLib() lib.setCursorColor(renderer.rendererPtr, color) - renderer.flushNativeOutput("cursor-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.flushNativeOutput("cursor-position") + this.outputStrategy.flush("cursor-position") } public setCursorStyle(style: CursorStyle, blinking: boolean = false, color?: RGBA): void { @@ -1233,7 +1153,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { if (color) { this.lib.setCursorColor(this.rendererPtr, color) } - this.flushNativeOutput("cursor-style") + this.outputStrategy.flush("cursor-style") } public setCursorColor(color: RGBA): void { @@ -1407,21 +1327,14 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.flushStdoutCache(this._splitHeight, true) } - if (this.awaitingDrain && this.drainListener && typeof this.stdout.off === "function") { - this.stdout.off("drain", this.drainListener) - this.drainListener = null - this.awaitingDrain = false - } + this.outputStrategy.destroy() if (this.stdin.setRawMode) { this.stdin.setRawMode(false) } this.stdin.removeListener("data", this.stdinListener) - if (this.jsFlush) { - this.lib.teardownTerminalToBuffer(this.rendererPtr) - this.flushNativeOutput("teardown") - } + this.outputStrategy.teardown() this.lib.destroyRenderer(this.rendererPtr) rendererTracker.removeRenderer(this) @@ -1440,11 +1353,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { } private async loop(): Promise { - if (this.rendering || this._isDestroyed || this.awaitingDrain) { - if (this.awaitingDrain && this.immediateRerenderRequested) { - this.pendingImmediateRerender = true - this.immediateRerenderRequested = false - } + if (this.rendering || this._isDestroyed || !this.outputStrategy.canRender()) { return } this.rendering = true @@ -1511,21 +1420,15 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.collectStatSample(overallFrameTime) } - if (this._isRunning && !this.awaitingDrain) { + if (this._isRunning && this.outputStrategy.canRender()) { const delay = Math.max(1, this.targetFrameTime - Math.floor(overallFrameTime)) this.renderTimeout = setTimeout(() => this.loop(), delay) - } else if (this.awaitingDrain) { - this.pendingImmediateRerender = this.pendingImmediateRerender || this.immediateRerenderRequested } } this.rendering = false if (this.immediateRerenderRequested) { this.immediateRerenderRequested = false - if (this.awaitingDrain) { - this.pendingImmediateRerender = true - } else { - this.loop() - } + this.loop() } } @@ -1548,12 +1451,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { } this.renderingNative = true - if (this.jsFlush) { - this.lib.renderIntoWriteBuffer(this.rendererPtr, force) - this.flushNativeOutput("frame") - } else { - this.lib.render(this.rendererPtr, force) - } + this.outputStrategy.render(force) // this.dumpStdoutBuffer(Date.now()) this.renderingNative = false } diff --git a/packages/core/src/testing/js-flush.test.ts b/packages/core/src/testing/js-flush.test.ts index 39b0af265..e0234dd82 100644 --- a/packages/core/src/testing/js-flush.test.ts +++ b/packages/core/src/testing/js-flush.test.ts @@ -23,14 +23,14 @@ class CollectingStream extends PassThrough { } } -describe("jsFlush mode", () => { +describe("outputMode: 'javascript'", () => { test("setup and render flush native buffers", async () => { const stdout = new CollectingStream() const stdin = new PassThrough() ;(stdin as any).isTTY = true const { renderer, renderOnce } = await createTestRenderer({ - jsFlush: true, + outputMode: 'javascript', stdout: stdout as unknown as NodeJS.WriteStream, stdin: stdin as unknown as NodeJS.ReadStream, useAlternateScreen: false, @@ -58,7 +58,7 @@ describe("jsFlush mode", () => { ;(stdin as any).isTTY = true const { renderer, renderOnce } = await createTestRenderer({ - jsFlush: true, + outputMode: 'javascript', stdout: stdout as unknown as NodeJS.WriteStream, stdin: stdin as unknown as NodeJS.ReadStream, useAlternateScreen: false, @@ -74,13 +74,13 @@ describe("jsFlush mode", () => { await renderOnce() expect(stdout.writes.length).toBeGreaterThan(0) - expect((renderer as any).awaitingDrain).toBe(true) + 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).awaitingDrain).toBe(false) + expect((renderer as any).outputStrategy.canRender()).toBe(true) } finally { renderer.destroy() stdout.destroy() From 461a6130bb1928c404d2a95d5aa98973738cf519 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 25 Oct 2025 22:02:33 -0400 Subject: [PATCH 4/4] fix --- packages/core/src/output-strategy.test.ts | 48 +++ packages/core/src/output-strategy.ts | 346 ++++++++---------- packages/core/src/renderer.stdout.test.ts | 29 ++ packages/core/src/renderer.ts | 42 ++- ...test.ts => javascript-output-mode.test.ts} | 31 +- packages/core/src/testing/stdout-mocks.ts | 53 +++ packages/core/src/testing/test-renderer.ts | 18 +- packages/core/src/zig/renderer.zig | 20 +- 8 files changed, 343 insertions(+), 244 deletions(-) create mode 100644 packages/core/src/output-strategy.test.ts create mode 100644 packages/core/src/renderer.stdout.test.ts rename packages/core/src/testing/{js-flush.test.ts => javascript-output-mode.test.ts} (73%) create mode 100644 packages/core/src/testing/stdout-mocks.ts 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 index 165b4a3a4..afc7f566e 100644 --- a/packages/core/src/output-strategy.ts +++ b/packages/core/src/output-strategy.ts @@ -1,206 +1,184 @@ -import type { Pointer } from "bun:ffi"; -import type { RenderLib } from "./zig"; +import type { Pointer } from "bun:ffi" +import type { RenderLib } from "./zig" + +export type StdoutWrite = NodeJS.WriteStream["write"] enum NativeWriteTarget { - TTY = 0, - BUFFER = 1, + TTY = 0, + BUFFER = 1, } export interface OutputStrategyOptions { - stdout: NodeJS.WriteStream; - stdin: NodeJS.ReadStream; - lib: RenderLib; - rendererPtr: Pointer; - emitFlush: (event: { bytes: number; reason: string }) => void; - onDrain: () => void; + 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; + 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, + 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(); - }); + 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 - } + 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.stdout.write(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(): Buffer | 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; - } - const chunk = this.nativeWriteBuffer.subarray(0, copied); - return Buffer.from(chunk); - } - - 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(); - }; + 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 type OutputMode = "native" | "javascript" -export function createOutputStrategy( - mode: OutputMode, - options: OutputStrategyOptions, -): OutputStrategy { - if (mode === 'javascript') { - return new JavaScriptOutputStrategy(options); - } - return new NativeOutputStrategy(options); +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 aeb609044..ca7caa02a 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -20,7 +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 } from "./output-strategy" +import { + createOutputStrategy, + type OutputStrategy, + type OutputMode, + type StdoutWrite, +} from "./output-strategy" registerEnvVar({ name: "OTUI_DUMP_CAPTURES", @@ -176,17 +181,13 @@ export async function createCliRenderer(config: CliRendererConfig = {}): Promise if (!rendererPtr) { throw new Error("Failed to create renderer") } - if (config.outputMode === 'javascript') { - config.useThread = false - } - if (config.useThread === undefined) { config.useThread = true } // 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) @@ -295,12 +296,14 @@ 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() @@ -395,15 +398,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 - const outputMode = config.outputMode ?? 'native' + const outputMode = config.outputMode ?? "native" + this.outputMode = outputMode const requestedUseThread = config.useThread === undefined ? false : config.useThread - this._useThread = outputMode === 'javascript' ? false : requestedUseThread + this._useThread = outputMode === "javascript" ? false : requestedUseThread this._splitHeight = config.experimental_splitHeight || 0 if (this._splitHeight > 0) { @@ -433,6 +438,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { stdin, lib, rendererPtr, + writeToTerminal: this.writeOut, emitFlush: (event) => this.emit("flush", event), onDrain: () => { if (!this._isDestroyed && (this._isRunning || this.immediateRerenderRequested)) { @@ -448,7 +454,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.startMemorySnapshotTimer() } - if (outputMode === 'native' && env.OTUI_OVERRIDE_STDOUT) { + if (env.OTUI_OVERRIDE_STDOUT) { this.stdout.write = this.interceptStdoutWrite.bind(this) } @@ -535,10 +541,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 && @@ -751,8 +753,15 @@ export class CliRenderer extends EventEmitter implements RenderContext { } 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, @@ -1158,6 +1167,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { 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 { diff --git a/packages/core/src/testing/js-flush.test.ts b/packages/core/src/testing/javascript-output-mode.test.ts similarity index 73% rename from packages/core/src/testing/js-flush.test.ts rename to packages/core/src/testing/javascript-output-mode.test.ts index e0234dd82..1b785fe30 100644 --- a/packages/core/src/testing/js-flush.test.ts +++ b/packages/core/src/testing/javascript-output-mode.test.ts @@ -1,36 +1,17 @@ import { describe, test, expect } from "bun:test" import { PassThrough } from "stream" -import { createTestRenderer } from "./test-renderer" - -class CollectingStream extends PassThrough { - public writes: Buffer[] = [] - public forcedBackpressure = false - public columns = 80 - public rows = 24 - public isTTY = true - 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 = [] - } -} +import { CollectingStdout } from "./stdout-mocks" +import { createTestRenderer } from "./test-renderer" describe("outputMode: 'javascript'", () => { test("setup and render flush native buffers", async () => { - const stdout = new CollectingStream() + const stdout = new CollectingStdout() const stdin = new PassThrough() ;(stdin as any).isTTY = true const { renderer, renderOnce } = await createTestRenderer({ - outputMode: 'javascript', + outputMode: "javascript", stdout: stdout as unknown as NodeJS.WriteStream, stdin: stdin as unknown as NodeJS.ReadStream, useAlternateScreen: false, @@ -53,12 +34,12 @@ describe("outputMode: 'javascript'", () => { }) test("backpressure pauses rendering until drain", async () => { - const stdout = new CollectingStream() + const stdout = new CollectingStdout() const stdin = new PassThrough() ;(stdin as any).isTTY = true const { renderer, renderOnce } = await createTestRenderer({ - outputMode: 'javascript', + outputMode: "javascript", stdout: stdout as unknown as NodeJS.WriteStream, stdin: stdin as unknown as NodeJS.ReadStream, useAlternateScreen: false, 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 aa5962a1e..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) @@ -69,15 +72,12 @@ async function setupTestRenderer(config: TestRendererOptions) { if (!rendererPtr) { throw new Error("Failed to create test renderer") } - if (config.jsFlush) { + if (config.useThread === undefined) { + config.useThread = true + } + + if (config.outputMode === "javascript" || process.platform === "linux") { config.useThread = false - } else { - if (config.useThread === undefined) { - config.useThread = true - } - if (process.platform === "linux") { - config.useThread = false - } } ziglib.setUseThread(rendererPtr, config.useThread) diff --git a/packages/core/src/zig/renderer.zig b/packages/core/src/zig/renderer.zig index 25ae916d8..e0711721e 100644 --- a/packages/core/src/zig/renderer.zig +++ b/packages/core/src/zig/renderer.zig @@ -318,7 +318,7 @@ pub const CliRenderer = struct { self.useAlternateScreen = useAlternateScreen; self.terminalSetup = true; - var ctx = self.beginTerminalWrite(); + const ctx = self.beginTerminalWrite(); defer self.endTerminalWrite(ctx); const writer = ctx.writer; @@ -338,7 +338,7 @@ pub const CliRenderer = struct { pub fn performShutdownSequence(self: *CliRenderer) void { if (!self.terminalSetup) return; - var ctx = self.beginTerminalWrite(); + const ctx = self.beginTerminalWrite(); defer self.endTerminalWrite(ctx); const writer = ctx.writer; @@ -476,7 +476,7 @@ pub const CliRenderer = struct { } pub fn setTerminalTitle(self: *CliRenderer, title: []const u8) void { - var ctx = self.beginTerminalWrite(); + const ctx = self.beginTerminalWrite(); defer self.endTerminalWrite(ctx); self.terminal.setTerminalTitle(ctx.writer, title); } @@ -833,7 +833,7 @@ pub const CliRenderer = struct { } pub fn clearTerminal(self: *CliRenderer) void { - var ctx = self.beginTerminalWrite(); + const ctx = self.beginTerminalWrite(); defer self.endTerminalWrite(ctx); ctx.writer.writeAll(ansi.ANSI.clearAndHome) catch {}; } @@ -972,31 +972,31 @@ pub const CliRenderer = struct { pub fn enableMouse(self: *CliRenderer, enableMovement: bool) void { _ = enableMovement; // TODO: Use this to control motion tracking levels - var ctx = self.beginTerminalWrite(); + const ctx = self.beginTerminalWrite(); defer self.endTerminalWrite(ctx); self.terminal.setMouseMode(ctx.writer, true) catch {}; } pub fn queryPixelResolution(self: *CliRenderer) void { - var ctx = self.beginTerminalWrite(); + const ctx = self.beginTerminalWrite(); defer self.endTerminalWrite(ctx); ctx.writer.writeAll(ansi.ANSI.queryPixelSize) catch {}; } pub fn disableMouse(self: *CliRenderer) void { - var ctx = self.beginTerminalWrite(); + const ctx = self.beginTerminalWrite(); defer self.endTerminalWrite(ctx); self.terminal.setMouseMode(ctx.writer, false) catch {}; } pub fn enableKittyKeyboard(self: *CliRenderer, flags: u8) void { - var ctx = self.beginTerminalWrite(); + const ctx = self.beginTerminalWrite(); defer self.endTerminalWrite(ctx); self.terminal.setKittyKeyboard(ctx.writer, true, flags) catch {}; } pub fn disableKittyKeyboard(self: *CliRenderer) void { - var ctx = self.beginTerminalWrite(); + const ctx = self.beginTerminalWrite(); defer self.endTerminalWrite(ctx); self.terminal.setKittyKeyboard(ctx.writer, false, 0) catch {}; } @@ -1007,7 +1007,7 @@ pub const CliRenderer = struct { pub fn processCapabilityResponse(self: *CliRenderer, response: []const u8) void { self.terminal.processCapabilityResponse(response); - var ctx = self.beginTerminalWrite(); + const ctx = self.beginTerminalWrite(); defer self.endTerminalWrite(ctx); self.terminal.enableDetectedFeatures(ctx.writer) catch {}; }