mirror of
https://github.com/aphrody-labs/opentui.git
synced 2026-10-13 21:08:53 +00:00
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:
1 parent
205a48b78a
commit
32d67005d0
2 files changed
+107
-25
No files matched your search
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user