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%.
This commit is contained in:
Simon Klee authored and GitHub committed 2026-09-27 15:43:01 +02:00
1 parent 205a48b78a
commit 32d67005d0
2 files changed
+107 -25

No files matched your search

+71
View File
@@ -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()
}
})
})
})
+36 -25
View File
@@ -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<T extends ArrayBufferView>(value: T): T | null {
return value.byteLength === 0 ? null : value
}
@@ -3327,6 +3334,8 @@ class FFIRenderLib implements RenderLib {
private clipboardServices = new Set<ClipboardServiceHandle>()
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,
)
}