diff --git a/packages/core/src/renderables/Code.ts b/packages/core/src/renderables/Code.ts index 7b4819394..6363b19c3 100644 --- a/packages/core/src/renderables/Code.ts +++ b/packages/core/src/renderables/Code.ts @@ -91,9 +91,9 @@ export class CodeRenderable extends TextBufferRenderable { if (this._content.length > 0) { if (this._initialStyledText && this._drawUnstyledText) { - this.textBuffer.setStyledText(this._initialStyledText) + this.setBufferStyledText(this._initialStyledText) } else { - this.textBuffer.setText(this._content) + this.setBufferText(this._content) } this.updateTextInfo() this._shouldRenderTextBuffer = this._drawUnstyledText || !this._filetype @@ -118,9 +118,9 @@ export class CodeRenderable extends TextBufferRenderable { } if (this._initialStyledText && this._drawUnstyledText) { - this.textBuffer.setStyledText(this._initialStyledText) + this.setBufferStyledText(this._initialStyledText) } else { - this.textBuffer.setText(value) + this.setBufferText(value) } this.setRenderedLineSources(undefined) this.updateTextInfo() @@ -303,9 +303,9 @@ export class CodeRenderable extends TextBufferRenderable { this._shouldRenderTextBuffer = true } else if (shouldDrawUnstyledNow) { if (this._initialStyledText) { - this.textBuffer.setStyledText(this._initialStyledText) + this.setBufferStyledText(this._initialStyledText) } else { - this.textBuffer.setText(content) + this.setBufferText(content) } this.setRenderedLineSources(undefined) this._shouldRenderTextBuffer = true @@ -390,10 +390,10 @@ export class CodeRenderable extends TextBufferRenderable { if (this.isDestroyed) return const styledText = new StyledText(chunks) - this.textBuffer.setStyledText(styledText) + this.setBufferStyledText(styledText) this.setRenderedLineSources(renderedLineSources) } else { - this.textBuffer.setText(content) + this.setBufferText(content) this.setRenderedLineSources(undefined) } @@ -410,7 +410,7 @@ export class CodeRenderable extends TextBufferRenderable { console.warn("Code highlighting failed, falling back to plain text:", error) if (this.isDestroyed) return - this.textBuffer.setText(content) + this.setBufferText(content) this.setRenderedLineSources(undefined) this._shouldRenderTextBuffer = true this._isHighlighting = false @@ -529,7 +529,7 @@ export class CodeRenderable extends TextBufferRenderable { } public getLineHighlights(lineIdx: number) { - return this.textBuffer.getLineHighlights(lineIdx) + return this.getBufferLineHighlights(lineIdx) } protected renderSelf(buffer: OptimizedBuffer): void { diff --git a/packages/core/src/renderables/Text.ts b/packages/core/src/renderables/Text.ts index 8d1e62033..6dd15b837 100644 --- a/packages/core/src/renderables/Text.ts +++ b/packages/core/src/renderables/Text.ts @@ -46,7 +46,7 @@ export class TextRenderable extends TextBufferRenderable { } private updateTextBuffer(styledText: StyledText): void { - this.textBuffer.setStyledText(styledText) + this.setBufferStyledText(styledText) this.clearChunks(styledText) } @@ -90,7 +90,7 @@ export class TextRenderable extends TextBufferRenderable { attributes: this._defaultAttributes, link: undefined, }) - this.textBuffer.setStyledText(new StyledText(chunks)) + this.setBufferStyledText(new StyledText(chunks)) this.refreshLocalSelection() this.yogaNode.markDirty() } @@ -124,6 +124,15 @@ export class TextRenderable extends TextBufferRenderable { this.requestRender() } + public loadFile(path: string): void { + this.loadBufferFile(path) + this.updateTextInfo() + } + + public get byteSize(): number { + return this.bufferByteSize + } + public onLifecyclePass = () => { this.updateTextFromNodes() } diff --git a/packages/core/src/renderables/TextBufferRenderable.test.ts b/packages/core/src/renderables/TextBufferRenderable.test.ts new file mode 100644 index 000000000..651a9e880 --- /dev/null +++ b/packages/core/src/renderables/TextBufferRenderable.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, beforeEach, afterEach, spyOn } from "bun:test" +import { TextRenderable } from "./Text.js" +import { TextNodeRenderable } from "./TextNode.js" +import { SyntaxStyle } from "../syntax-style.js" +import { StyledText } from "../lib/styled-text.js" +import { RGBA } from "../lib/RGBA.js" +import { createTestRenderer, type TestRenderer } from "../testing/test-renderer.js" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +let currentRenderer: TestRenderer +let renderOnce: () => Promise + +describe("TextBufferRenderable syntax style lifecycle", () => { + beforeEach(async () => { + ;({ renderer: currentRenderer, renderOnce } = await createTestRenderer({ + width: 50, + height: 10, + })) + }) + + afterEach(() => { + currentRenderer.destroy() + }) + + it("does not allocate SyntaxStyle for TextNodeRenderable tree that only inherits default styles", async () => { + const createSpy = spyOn(SyntaxStyle, "create") + try { + const text = new TextRenderable(currentRenderer, { + id: "react-like-text", + content: "", // Start empty, like React does + fg: RGBA.fromValues(1, 1, 1, 1), // Explicit default + }) + currentRenderer.root.add(text) + + // Add a child node with no explicit style, simulating Hello + const child = new TextNodeRenderable({}) + child.add("Hello from React/Solid") + text.add(child) + + await renderOnce() + + expect(text.plainText).toBe("Hello from React/Solid") + expect(createSpy).not.toHaveBeenCalled() + + // Now add a styled child, it SHOULD allocate + const styledChild = new TextNodeRenderable({ fg: RGBA.fromHex("#ff0000") }) + styledChild.add(" Styled") + text.add(styledChild) + + await renderOnce() + + expect(createSpy).toHaveBeenCalledTimes(1) + } finally { + createSpy.mockRestore() + } + }) + + it("does not allocate a native SyntaxStyle when constructing and rendering plain text", async () => { + const createSpy = spyOn(SyntaxStyle, "create") + try { + const text = new TextRenderable(currentRenderer, { + id: "plain-text", + content: "No highlighting needed here", + }) + currentRenderer.root.add(text) + await renderOnce() + + expect(createSpy).not.toHaveBeenCalled() + + currentRenderer.root.remove(text) + expect(() => text.destroy()).not.toThrow() + expect(createSpy).not.toHaveBeenCalled() + } finally { + createSpy.mockRestore() + } + }) + + it("lazily creates a single SyntaxStyle on explicit request and destroys it with the renderable", async () => { + class Probe extends TextRenderable { + public poke() { + this.ensureSyntaxStyle() + } + } + + const createSpy = spyOn(SyntaxStyle, "create") + try { + const probe = new Probe(currentRenderer, { + id: "probe-text", + content: "Lazy style", + }) + currentRenderer.root.add(probe) + await renderOnce() + + expect(createSpy).not.toHaveBeenCalled() + + probe.poke() + expect(createSpy).toHaveBeenCalledTimes(1) + + probe.poke() // should be idempotent + expect(createSpy).toHaveBeenCalledTimes(1) + + currentRenderer.root.remove(probe) + expect(() => probe.destroy()).not.toThrow() + } finally { + createSpy.mockRestore() + } + }) + + it("throws if ensureSyntaxStyle is called after destruction", async () => { + class Probe extends TextRenderable { + public poke() { + this.ensureSyntaxStyle() + } + } + + const probe = new Probe(currentRenderer, { + id: "probe-text", + content: "Lazy style", + }) + probe.destroy() + + expect(() => probe.poke()).toThrow("Cannot allocate SyntaxStyle: renderable is already destroyed") + }) + + it("still applies chunk styles for styled content, allocating the SyntaxStyle lazily", async () => { + const chunkFg = RGBA.fromHex("#ff0000") + const createSpy = spyOn(SyntaxStyle, "create") + try { + const text = new TextRenderable(currentRenderer, { + id: "styled-text", + content: new StyledText([{ __isChunk: true as const, text: "styled", fg: chunkFg }]), + }) + currentRenderer.root.add(text) + await renderOnce() + + expect(createSpy).toHaveBeenCalledTimes(1) + + const { buffers, width } = currentRenderer.currentRenderBuffer + for (let col = text.x; col < text.x + "styled".length; col++) { + const index = text.y * width + col + const fg = RGBA.fromArray(buffers.fg.slice(index * 4, index * 4 + 4)) + expect(fg.toInts()).toEqual(chunkFg.toInts()) + } + } finally { + createSpy.mockRestore() + } + }) + + it("loadFile triggers updateTextInfo to update layout and render state", async () => { + const text = new TextRenderable(currentRenderer, { + id: "load-file-text", + content: "initial", + }) + currentRenderer.root.add(text) + await renderOnce() + + const updateSpy = spyOn(text as any, "updateTextInfo") + + // Create a dummy file to load in a proper temp directory + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "opentui-test-")) + const dummyPath = path.join(tmpDir, "dummy_test_file.txt") + fs.writeFileSync(dummyPath, "loaded content") + + try { + text.loadFile(dummyPath) + expect(updateSpy).toHaveBeenCalledTimes(1) + expect(text.plainText).toBe("loaded content") + } finally { + updateSpy.mockRestore() + if (fs.existsSync(dummyPath)) { + fs.unlinkSync(dummyPath) + } + if (fs.existsSync(tmpDir)) { + fs.rmdirSync(tmpDir) + } + } + }) +}) diff --git a/packages/core/src/renderables/TextBufferRenderable.ts b/packages/core/src/renderables/TextBufferRenderable.ts index 1d37aa605..b39d9fab6 100644 --- a/packages/core/src/renderables/TextBufferRenderable.ts +++ b/packages/core/src/renderables/TextBufferRenderable.ts @@ -1,5 +1,6 @@ import { Renderable, type RenderableOptions } from "../Renderable.js" import { convertGlobalToLocalSelection, Selection, type LocalSelectionBounds } from "../lib/selection.js" +import type { StyledText } from "../lib/styled-text.js" import { TextBuffer, type TextChunk } from "../text-buffer.js" import { TextBufferView } from "../text-buffer-view.js" import { RGBA, parseColor } from "../lib/RGBA.js" @@ -8,6 +9,15 @@ import type { OptimizedBuffer } from "../buffer.js" import { NativeMeasureTargetKind, resolveRenderLib, type LineInfo, type NativeRenderableHandle } from "../zig.js" import { SyntaxStyle } from "../syntax-style.js" +function chunkCarriesStyle(chunk: TextChunk, defaultFg: RGBA, defaultBg: RGBA, defaultAttributes: number): boolean { + return ( + (chunk.fg !== undefined && chunk.fg !== defaultFg) || + (chunk.bg !== undefined && chunk.bg !== defaultBg) || + chunk.link !== undefined || + (chunk.attributes !== undefined && chunk.attributes !== defaultAttributes && chunk.attributes !== 0) + ) +} + export interface TextBufferOptions extends RenderableOptions { fg?: string | RGBA bg?: string | RGBA @@ -38,9 +48,9 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf protected _truncate: boolean = false protected _firstLineOffset: number = 0 - protected textBuffer: TextBuffer - protected textBufferView: TextBufferView - protected _textBufferSyntaxStyle: SyntaxStyle + private _textBuffer: TextBuffer + private _textBufferView: TextBufferView + private _textBufferSyntaxStyle: SyntaxStyle | null = null private nativeRenderable: NativeRenderableHandle | null = null protected _defaultOptions = { @@ -72,41 +82,82 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf : this._defaultOptions.tabIndicatorColor this._truncate = options.truncate ?? this._defaultOptions.truncate - this.textBuffer = TextBuffer.create(this._ctx.widthMethod) - this.textBufferView = TextBufferView.create(this.textBuffer) + this._textBuffer = TextBuffer.create(this._ctx.widthMethod) + this._textBufferView = TextBufferView.create(this._textBuffer) this._firstLineOffset = ctx.claimFirstLineOffset?.(this) ?? 0 - this._textBufferSyntaxStyle = SyntaxStyle.create() - this.textBuffer.setSyntaxStyle(this._textBufferSyntaxStyle) - - this.textBufferView.setWrapMode(this._wrapMode) - this.textBufferView.setFirstLineOffset(this._firstLineOffset) + this._textBufferView.setWrapMode(this._wrapMode) + this._textBufferView.setFirstLineOffset(this._firstLineOffset) this.setupNativeRenderable() - this.textBuffer.setDefaultFg(this._defaultFg) - this.textBuffer.setDefaultBg(this._defaultBg) - this.textBuffer.setDefaultAttributes(this._defaultAttributes) + this._textBuffer.setDefaultFg(this._defaultFg) + this._textBuffer.setDefaultBg(this._defaultBg) + this._textBuffer.setDefaultAttributes(this._defaultAttributes) if (this._tabIndicator !== undefined) { - this.textBufferView.setTabIndicator(this._tabIndicator) + this._textBufferView.setTabIndicator(this._tabIndicator) } if (this._tabIndicatorColor !== undefined) { - this.textBufferView.setTabIndicatorColor(this._tabIndicatorColor) + this._textBufferView.setTabIndicatorColor(this._tabIndicatorColor) } if (this._wrapMode !== "none" && this.width > 0) { - this.textBufferView.setWrapWidth(this.width) + this._textBufferView.setWrapWidth(this.width) } if (this.width > 0 && this.height > 0) { - this.textBufferView.setViewport(this._scrollX, this._scrollY, this.width, this.height) + this._textBufferView.setViewport(this._scrollX, this._scrollY, this.width, this.height) } - this.textBufferView.setTruncate(this._truncate) + this._textBufferView.setTruncate(this._truncate) this.updateTextInfo() } + protected ensureSyntaxStyle(): void { + if (this.isDestroyed) { + throw new Error("Cannot allocate SyntaxStyle: renderable is already destroyed") + } + if (!this._textBufferSyntaxStyle) { + this._textBufferSyntaxStyle = SyntaxStyle.create() + this._textBuffer.setSyntaxStyle(this._textBufferSyntaxStyle) + } + } + + /** + * Sets styled text on the backing buffer. The native buffer only applies chunk styles + * (fg, bg, attributes, links) when a SyntaxStyle is attached, so this attaches the lazily + * created style first — but only when a chunk actually carries style information, keeping + * plain-text renderables free of native SyntaxStyle handles. + */ + protected setBufferStyledText(styledText: StyledText): void { + if ( + !this._textBufferSyntaxStyle && + styledText.chunks.some((chunk) => + chunkCarriesStyle(chunk, this._defaultFg, this._defaultBg, this._defaultAttributes), + ) + ) { + this.ensureSyntaxStyle() + } + this._textBuffer.setStyledText(styledText) + } + + protected setBufferText(text: string): void { + this._textBuffer.setText(text) + } + + protected loadBufferFile(path: string): void { + this._textBuffer.loadFile(path) + } + + protected get bufferByteSize(): number { + return this._textBuffer.byteSize + } + + protected getBufferLineHighlights(lineIdx: number) { + return this._textBuffer.getLineHighlights(lineIdx) + } + protected onMouseEvent(event: any): void { if (event.type === "scroll") { this.handleScroll(event) @@ -134,15 +185,15 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf } public get lineInfo(): LineInfo { - return this.textBufferView.logicalLineInfo + return this._textBufferView.logicalLineInfo } public get lineCount(): number { - return this.textBuffer.getLineCount() + return this._textBuffer.getLineCount() } public get virtualLineCount(): number { - return this.textBufferView.getVirtualLineCount() + return this._textBufferView.getVirtualLineCount() } public get scrollY(): number { @@ -192,16 +243,16 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf protected updateViewportOffset(): void { // Update the viewport with the new scroll position if (this.width > 0 && this.height > 0) { - this.textBufferView.setViewport(this._scrollX, this._scrollY, this.width, this.height) + this._textBufferView.setViewport(this._scrollX, this._scrollY, this.width, this.height) } } get plainText(): string { - return this.textBuffer.getPlainText() + return this._textBuffer.getPlainText() } get textLength(): number { - return this.textBuffer.length + return this._textBuffer.length } get fg(): RGBA { @@ -212,7 +263,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf const newColor = parseColor(value ?? this._defaultOptions.fg) if (this._defaultFg !== newColor) { this._defaultFg = newColor - this.textBuffer.setDefaultFg(this._defaultFg) + this._textBuffer.setDefaultFg(this._defaultFg) this.onFgChanged(newColor) this.requestRender() } @@ -256,7 +307,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf const newColor = parseColor(value ?? this._defaultOptions.bg) if (this._defaultBg !== newColor) { this._defaultBg = newColor - this.textBuffer.setDefaultBg(this._defaultBg) + this._textBuffer.setDefaultBg(this._defaultBg) this.onBgChanged(newColor) this.requestRender() } @@ -269,7 +320,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf set attributes(value: number) { if (this._defaultAttributes !== value) { this._defaultAttributes = value - this.textBuffer.setDefaultAttributes(this._defaultAttributes) + this._textBuffer.setDefaultAttributes(this._defaultAttributes) this.onAttributesChanged(value) this.requestRender() } @@ -282,9 +333,9 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf set wrapMode(value: "none" | "char" | "word") { if (this._wrapMode !== value) { this._wrapMode = value - this.textBufferView.setWrapMode(this._wrapMode) + this._textBufferView.setWrapMode(this._wrapMode) if (value !== "none" && this.width > 0) { - this.textBufferView.setWrapWidth(this.width) + this._textBufferView.setWrapWidth(this.width) } // Changing wrap mode can change dimensions, so mark yoga node dirty to trigger re-measurement this.yogaNode.markDirty() @@ -300,7 +351,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf if (this._tabIndicator !== value) { this._tabIndicator = value if (value !== undefined) { - this.textBufferView.setTabIndicator(value) + this._textBufferView.setTabIndicator(value) } this.requestRender() } @@ -315,7 +366,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf if (this._tabIndicatorColor !== newColor) { this._tabIndicatorColor = newColor if (newColor !== undefined) { - this.textBufferView.setTabIndicatorColor(newColor) + this._textBufferView.setTabIndicatorColor(newColor) } this.requestRender() } @@ -328,13 +379,13 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf set truncate(value: boolean) { if (this._truncate !== value) { this._truncate = value - this.textBufferView.setTruncate(value) + this._textBufferView.setTruncate(value) this.requestRender() } } protected onResize(width: number, height: number): void { - this.textBufferView.setViewport(this._scrollX, this._scrollY, width, height) + this._textBufferView.setViewport(this._scrollX, this._scrollY, width, height) this.yogaNode.markDirty() this.requestRender() this.emit("line-info-change") @@ -349,11 +400,11 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf private updateLocalSelection(localSelection: LocalSelectionBounds | null): boolean { if (!localSelection?.isActive) { - this.textBufferView.resetLocalSelection() + this._textBufferView.resetLocalSelection() return true } - return this.textBufferView.setLocalSelection( + return this._textBufferView.setLocalSelection( localSelection.anchorX, localSelection.anchorY, localSelection.focusX, @@ -388,7 +439,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf !lib.nativeRenderableSetMeasureTarget( nativeRenderable, NativeMeasureTargetKind.TextBufferView, - this.textBufferView.ptr, + this._textBufferView.ptr, ) ) { lib.destroyNativeRenderable(nativeRenderable) @@ -412,10 +463,10 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf let changed: boolean if (!localSelection?.isActive) { - this.textBufferView.resetLocalSelection() + this._textBufferView.resetLocalSelection() changed = true } else if (selection?.isStart) { - changed = this.textBufferView.setLocalSelection( + changed = this._textBufferView.setLocalSelection( localSelection.anchorX, localSelection.anchorY, localSelection.focusX, @@ -424,7 +475,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf this._selectionFg, ) } else { - changed = this.textBufferView.updateLocalSelection( + changed = this._textBufferView.updateLocalSelection( localSelection.anchorX, localSelection.anchorY, localSelection.focusX, @@ -442,15 +493,15 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf } getSelectedText(): string { - return this.textBufferView.getSelectedText() + return this._textBufferView.getSelectedText() } hasSelection(): boolean { - return this.textBufferView.hasSelection() + return this._textBufferView.hasSelection() } getSelection(): { start: number; end: number } | null { - return this.textBufferView.getSelection() + return this._textBufferView.getSelection() } render(buffer: OptimizedBuffer, deltaTime: number): void { @@ -471,8 +522,8 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf } protected renderSelf(buffer: OptimizedBuffer): void { - if (this.textBuffer.ptr) { - buffer.drawTextBuffer(this.textBufferView, this._screenX, this._screenY) + if (this._textBuffer.ptr) { + buffer.drawTextBuffer(this._textBufferView, this._screenX, this._screenY) } } @@ -483,10 +534,13 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf resolveRenderLib().destroyNativeRenderable(this.nativeRenderable) this.nativeRenderable = null } - this.textBuffer.setSyntaxStyle(null) - this._textBufferSyntaxStyle.destroy() - this.textBufferView.destroy() - this.textBuffer.destroy() + if (this._textBufferSyntaxStyle) { + this._textBuffer.setSyntaxStyle(null) + this._textBufferSyntaxStyle.destroy() + this._textBufferSyntaxStyle = null + } + this._textBufferView.destroy() + this._textBuffer.destroy() super.destroy() } diff --git a/packages/core/src/renderables/__tests__/LineNumberRenderable.test.ts b/packages/core/src/renderables/__tests__/LineNumberRenderable.test.ts index 93e9a7c0b..c27b0611e 100644 --- a/packages/core/src/renderables/__tests__/LineNumberRenderable.test.ts +++ b/packages/core/src/renderables/__tests__/LineNumberRenderable.test.ts @@ -90,7 +90,7 @@ Press ESC to return to main menu` class MockTextBuffer extends TextBufferRenderable { constructor(ctx: any, options: any) { super(ctx, options) - this.textBuffer.setText(options.text || "") + this.setBufferText(options.text || "") } } diff --git a/packages/examples/src/text-wrap.ts b/packages/examples/src/text-wrap.ts index b38cc01b5..79c7ac551 100644 --- a/packages/examples/src/text-wrap.ts +++ b/packages/examples/src/text-wrap.ts @@ -605,11 +605,10 @@ export function run(renderer: CliRenderer): void { textRenderable.onLifecyclePass() // Load file directly into the text buffer - const textBuffer = (textRenderable as any).textBuffer - textBuffer.loadFile(filePath) + textRenderable.loadFile(filePath) // Get the text buffer size after loading (in bytes) - const textBufferBytes = textBuffer.byteSize + const textBufferBytes = textRenderable.byteSize const textBufferMB = (textBufferBytes / (1024 * 1024)).toFixed(2) // Update status