Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions packages/core/src/renderables/Code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/renderables/Text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class TextRenderable extends TextBufferRenderable {
}

private updateTextBuffer(styledText: StyledText): void {
this.textBuffer.setStyledText(styledText)
this.setBufferStyledText(styledText)
this.clearChunks(styledText)
}

Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand Down
180 changes: 180 additions & 0 deletions packages/core/src/renderables/TextBufferRenderable.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>

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 <text>Hello</text>
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)
}
}
})
})
Loading