From 32d67005d0dd61e63dff24756d5b9061bc504325 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sun, 27 Sep 2026 15:43:01 +0200 Subject: [PATCH] core: encode draw text into reused storage (#1538) bufferDrawText and drawBox titles allocated a new Uint8Array through TextEncoder.encode on every call. Text now encodes into a reused scratch buffer with encodeInto. A UTF-16 code unit encodes to at most three bytes, so text that fits a third of the scratch never overflows it, and longer text allocates as before. Text still converts as TextEncoder.encode converted it: undefined draws nothing and other values use ToString. Both box titles convert before either encodes, so a title whose toString draws cannot overwrite the other title's bytes. On Node, a 10-character drawText drops from 9,859 to 6,012 user-space instructions and a box with two titles from 28,849 to 22,154. On Bun, where encode was already cheap, short text drops about 5%. --- packages/core/src/buffer.test.ts | 71 ++++++++++++++++++++++++++++++++ packages/core/src/zig.ts | 61 ++++++++++++++++----------- 2 files changed, 107 insertions(+), 25 deletions(-) diff --git a/packages/core/src/buffer.test.ts b/packages/core/src/buffer.test.ts index 25c54935..aa773e55 100644 --- a/packages/core/src/buffer.test.ts +++ b/packages/core/src/buffer.test.ts @@ -365,4 +365,75 @@ describe("OptimizedBuffer", () => { parent.destroy() }) }) + + describe("draw text encoding", () => { + const white = RGBA.fromInts(255, 255, 255) + const black = RGBA.fromInts(0, 0, 0) + const rows = (target: OptimizedBuffer) => + new TextDecoder() + .decode(target.getRealCharBytes(true)) + .split("\n") + .map((row) => row.trimEnd()) + + it("draws non-string text as TextEncoder converts it", () => { + buffer.clear(black) + buffer.drawText(123 as never, 0, 0, white) + buffer.drawText(["a", "b"] as never, 0, 1, white) + buffer.drawText(undefined as never, 0, 2, white) + buffer.drawText(null as never, 0, 3, white) + expect(rows(buffer).slice(0, 4)).toEqual(["123", "a,b", "", "null"]) + }) + + it("draws only the latest text after longer and multi-byte text", () => { + buffer.clear(black) + buffer.drawText("é漢😀 wide", 0, 0, white) + buffer.drawText("x".repeat(5000), 0, 1, white) + buffer.drawText("ok", 0, 1, white) + buffer.drawText("é漢😀", 0, 2, white) + buffer.drawText("ab", 0, 2, white) + expect(rows(buffer).slice(0, 3)).toEqual(["é漢😀 wide", "ok" + "x".repeat(18), "ab 😀"]) + }) + + it("draws all of a multi-byte text whose UTF-8 is longer than its UTF-16 length", () => { + const wide = OptimizedBuffer.create(3000, 1, "unicode", { id: "wide-buffer" }) + try { + const text = "漢".repeat(1400) + wide.drawText(text, 0, 0, white) + expect(rows(wide)[0]).toBe(text) + } finally { + wide.destroy() + } + }) + + it("converts both box titles before a title's toString can draw", () => { + const other = OptimizedBuffer.create(12, 1, "unicode", { id: "other-buffer" }) + try { + other.clear(black) + buffer.clear(black) + const bottomTitle = { + toString() { + other.drawText("XYZ", 0, 0, white) + return "BOT" + }, + } + buffer.drawBox({ + x: 0, + y: 0, + width: 12, + height: 3, + border: true, + borderColor: white, + backgroundColor: black, + title: "TOP", + bottomTitle: bottomTitle as never, + }) + const [top, , bottom] = rows(buffer) + expect(top).toContain("TOP") + expect(bottom).toContain("BOT") + expect(rows(other)[0]).toBe("XYZ") + } finally { + other.destroy() + } + }) + }) }) diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index c712feec..6094e5f6 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -352,6 +352,13 @@ function isFFIU32(value: number): boolean { return Number.isInteger(value) && value >= 0 && value <= MAX_FFI_U32 } +const DRAW_TEXT_SCRATCH_BYTES = 4096 + +/** Converts draw text as TextEncoder.encode does: undefined becomes "", other values use ToString. */ +function drawTextString(text: unknown): string { + return typeof text === "string" ? text : text === undefined ? "" : `${text}` +} + function viewOrNull(value: T): T | null { return value.byteLength === 0 ? null : value } @@ -3327,6 +3334,8 @@ class FFIRenderLib implements RenderLib { private clipboardServices = new Set() public readonly encoder: TextEncoder = new TextEncoder() public readonly decoder: TextDecoder = new TextDecoder() + private readonly drawTextScratch = new Uint8Array(DRAW_TEXT_SCRATCH_BYTES) + private readonly drawBottomTitleScratch = new Uint8Array(DRAW_TEXT_SCRATCH_BYTES) private logCallbackWrapper: FFICallbackInstance | null = null private eventCallbackWrapper: FFICallbackInstance | null = null private eventSinkPtr: EventSinkHandle | null = null @@ -4002,20 +4011,19 @@ class FFIRenderLib implements RenderLib { bgColor?: RGBA, attributes?: number, ) { - const textBytes = this.encoder.encode(text) const bg = optionalRgbaBuffer(bgColor) const fg = rgbaBuffer(color) + const string = drawTextString(text) + const bytes = this.drawTextStorage(string, this.drawTextScratch) + // No JavaScript runs between this encode and the call, so every draw can reuse the scratch. + const length = this.encoder.encodeInto(string, bytes).written - this.opentui.symbols.bufferDrawText( - buffer, - viewOrNull(textBytes), - textBytes.byteLength, - x, - y, - fg, - bg, - attributes ?? 0, - ) + this.opentui.symbols.bufferDrawText(buffer, length === 0 ? null : bytes, length, x, y, fg, bg, attributes ?? 0) + } + + /** A UTF-16 code unit encodes to at most three UTF-8 bytes, so only longer text allocates. */ + private drawTextStorage(text: string, scratch: Uint8Array): Uint8Array { + return text.length * 3 <= scratch.byteLength ? scratch : new Uint8Array(text.length * 3) } public bufferSetCellWithAlphaBlending( @@ -4241,13 +4249,16 @@ class FFIRenderLib implements RenderLib { title: string | null, bottomTitle: string | null, ): void { - const titleBytes = title ? this.encoder.encode(title) : null - const titleLen = titleBytes?.byteLength ?? 0 - const titleBuffer = titleBytes ? titleBytes : null - - const bottomTitleBytes = bottomTitle ? this.encoder.encode(bottomTitle) : null - const bottomTitleLen = bottomTitleBytes?.byteLength ?? 0 - const bottomTitleBuffer = bottomTitleBytes ? bottomTitleBytes : null + const border = rgbaBuffer(borderColor) + const background = rgbaBuffer(backgroundColor) + const titleFg = rgbaBuffer(titleColor) + // Convert both titles before encoding either, because a title's toString can draw and reuse the scratch. + const top = title ? drawTextString(title) : null + const bottom = bottomTitle ? drawTextString(bottomTitle) : null + const topBytes = top === null ? null : this.drawTextStorage(top, this.drawTextScratch) + const bottomBytes = bottom === null ? null : this.drawTextStorage(bottom, this.drawBottomTitleScratch) + const topLength = top === null ? 0 : this.encoder.encodeInto(top, topBytes!).written + const bottomLength = bottom === null ? 0 : this.encoder.encodeInto(bottom, bottomBytes!).written this.opentui.symbols.bufferDrawBox( buffer, @@ -4257,13 +4268,13 @@ class FFIRenderLib implements RenderLib { height, borderChars, packedOptions, - rgbaBuffer(borderColor), - rgbaBuffer(backgroundColor), - rgbaBuffer(titleColor), - titleBuffer, - titleLen, - bottomTitleBuffer, - bottomTitleLen, + border, + background, + titleFg, + topBytes, + topLength, + bottomBytes, + bottomLength, ) }