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
63 changes: 63 additions & 0 deletions packages/core/src/renderables/Code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { createTestRenderer, type TestRenderer, MockTreeSitterClient, type MockM
import { TreeSitterClient } from "../lib/tree-sitter"
import type { SimpleHighlight } from "../lib/tree-sitter/types"
import { BoxRenderable } from "./Box"
import type { CapturedFrame } from "../types"

let currentRenderer: TestRenderer
let renderOnce: () => Promise<void>
let captureFrame: () => string
let captureSpans: () => CapturedFrame
let mockMouse: MockMouse
let resize: (width: number, height: number) => void

Expand All @@ -18,6 +20,7 @@ beforeEach(async () => {
currentRenderer = testRenderer.renderer
renderOnce = testRenderer.renderOnce
captureFrame = testRenderer.captureCharFrame
captureSpans = testRenderer.captureSpans
mockMouse = testRenderer.mockMouse
resize = testRenderer.resize
})
Expand Down Expand Up @@ -226,6 +229,66 @@ test("CodeRenderable - uses fallback rendering when highlighting throws error",
expect(codeRenderable.plainText).toBe("const message = 'hello world';")
})

test("CodeRenderable - plain fallback uses syntax default colors", async () => {
const syntaxStyle = SyntaxStyle.fromStyles({
default: {
fg: RGBA.fromHex("#111111"),
bg: RGBA.fromHex("#f0f0f0"),
},
})

const mockClient = new MockTreeSitterClient()
mockClient.setMockResult({ warning: "No parser available", highlights: [] })

const codeRenderable = new CodeRenderable(currentRenderer, {
id: "test-code-fallback-colors",
content: "echo test",
filetype: "unsupported-language",
syntaxStyle,
treeSitterClient: mockClient,
conceal: false,
drawUnstyledText: false,
})

currentRenderer.root.add(codeRenderable)

expect(codeRenderable.fg.equals(RGBA.fromHex("#111111"))).toBe(true)
expect(codeRenderable.bg.equals(RGBA.fromHex("#f0f0f0"))).toBe(true)

await renderOnce()
mockClient.resolveHighlightOnce(0)
await new Promise((resolve) => setTimeout(resolve, 10))
await renderOnce()

const spans = captureSpans().lines[0]?.spans ?? []
expect(spans.some((span) => span.text.includes("echo test") && span.fg.equals(RGBA.fromHex("#111111")))).toBe(true)
expect(spans.some((span) => span.bg.equals(RGBA.fromHex("#f0f0f0")))).toBe(true)
})

test("CodeRenderable - handles missing syntaxStyle before setter", async () => {
const codeRenderable = new CodeRenderable(currentRenderer, {
id: "test-code-missing-style",
content: "const value = 1",
filetype: "javascript",
syntaxStyle: undefined as any,
conceal: false,
} as any)

currentRenderer.root.add(codeRenderable)
await renderOnce()

expect(codeRenderable.content).toBe("const value = 1")
expect(captureFrame()).toContain("const value = 1")

const syntaxStyle = SyntaxStyle.fromStyles({
default: { fg: RGBA.fromHex("#222222") },
})
codeRenderable.syntaxStyle = syntaxStyle
await renderOnce()

expect(codeRenderable.syntaxStyle).toBe(syntaxStyle)
})

test("CodeRenderable - handles empty content", async () => {
const syntaxStyle = SyntaxStyle.fromStyles({
default: { fg: RGBA.fromValues(1, 1, 1, 1) },
Expand Down
62 changes: 57 additions & 5 deletions packages/core/src/renderables/Code.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import { type RenderContext } from "../types"
import { StyledText } from "../lib/styled-text"
import { SyntaxStyle } from "../syntax-style"
import { SyntaxStyle, type StyleDefinition } from "../syntax-style"
import { getTreeSitterClient, treeSitterToStyledText, TreeSitterClient } from "../lib/tree-sitter"
import { TextBufferRenderable, type TextBufferOptions } from "./TextBufferRenderable"
import type { OptimizedBuffer } from "../buffer"
import type { SimpleHighlight } from "../lib/tree-sitter/types"
import type { TextChunk } from "../text-buffer"
import { treeSitterToTextChunks } from "../lib/tree-sitter-styled-text"
import { createTextAttributes } from "../utils"

function defaultStyle(syntaxStyle: SyntaxStyle | undefined): StyleDefinition | undefined {
if (!syntaxStyle) return undefined
return syntaxStyle.getStyle("default")
}

function defaultAttributes(style: StyleDefinition | undefined): number {
if (!style) return 0
return createTextAttributes({
bold: style.bold,
italic: style.italic,
underline: style.underline,
dim: style.dim,
})
}

export interface HighlightContext {
content: string
Expand Down Expand Up @@ -56,6 +72,9 @@ export class CodeRenderable extends TextBufferRenderable {
private _lastHighlights: SimpleHighlight[] = []
private _onHighlight?: OnHighlightCallback
private _onChunks?: OnChunksCallback
private _autoFg: boolean
private _autoBg: boolean
private _autoAttributes: boolean

protected _contentDefaultOptions = {
content: "",
Expand All @@ -65,7 +84,13 @@ export class CodeRenderable extends TextBufferRenderable {
} satisfies Partial<CodeOptions>

constructor(ctx: RenderContext, options: CodeOptions) {
super(ctx, options)
const style = defaultStyle(options.syntaxStyle)
super(ctx, {
...options,
fg: options.fg ?? style?.fg,
bg: options.bg ?? style?.bg,
attributes: options.attributes ?? defaultAttributes(style),
})

this._content = options.content ?? this._contentDefaultOptions.content
this._filetype = options.filetype
Expand All @@ -76,6 +101,9 @@ export class CodeRenderable extends TextBufferRenderable {
this._streaming = options.streaming ?? this._contentDefaultOptions.streaming
this._onHighlight = options.onHighlight
this._onChunks = options.onChunks
this._autoFg = options.fg === undefined
this._autoBg = options.bg === undefined
this._autoAttributes = options.attributes === undefined

if (this._content.length > 0) {
this.textBuffer.setText(this._content)
Expand Down Expand Up @@ -123,10 +151,24 @@ export class CodeRenderable extends TextBufferRenderable {
set syntaxStyle(value: SyntaxStyle) {
if (this._syntaxStyle !== value) {
this._syntaxStyle = value
this.applySyntaxDefaults()
this._highlightsDirty = true
}
}

private applySyntaxDefaults(): void {
const style = defaultStyle(this._syntaxStyle)
if (this._autoFg) {
this.fg = style?.fg
}
if (this._autoBg) {
this.bg = style?.bg
}
if (this._autoAttributes) {
this.attributes = defaultAttributes(style)
}
}

get conceal(): boolean {
return this._conceal
}
Expand Down Expand Up @@ -232,9 +274,19 @@ export class CodeRenderable extends TextBufferRenderable {
private async startHighlight(): Promise<void> {
const content = this._content
const filetype = this._filetype
const syntaxStyle = this._syntaxStyle
const snapshotId = ++this._highlightSnapshotId

if (!filetype) return
if (!syntaxStyle) {
this.textBuffer.setText(content)
this._shouldRenderTextBuffer = true
this._isHighlighting = false
this._highlightsDirty = false
this.updateTextInfo()
this.requestRender()
return
}

const isInitialContent = this._streaming && !this._hadInitialContent
if (isInitialContent) {
Expand All @@ -258,7 +310,7 @@ export class CodeRenderable extends TextBufferRenderable {
const context: HighlightContext = {
content,
filetype,
syntaxStyle: this._syntaxStyle,
syntaxStyle,
}
const modified = await this._onHighlight(highlights, context)
if (modified !== undefined) {
Expand All @@ -282,11 +334,11 @@ export class CodeRenderable extends TextBufferRenderable {
const context: ChunkRenderContext = {
content,
filetype,
syntaxStyle: this._syntaxStyle,
syntaxStyle,
highlights,
}

let chunks = treeSitterToTextChunks(content, highlights, this._syntaxStyle, {
let chunks = treeSitterToTextChunks(content, highlights, syntaxStyle, {
enabled: this._conceal,
})

Expand Down
75 changes: 73 additions & 2 deletions packages/core/src/renderables/Markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type TextTableContent,
} from "./TextTable"
import type { TreeSitterClient } from "../lib/tree-sitter"
import { extToFiletype } from "../lib/tree-sitter/resolve-ft"
import { parseMarkdownIncremental, type ParseState } from "./markdown-parser"
import type { OptimizedBuffer } from "../buffer"
import { detectLinks } from "../lib/detect-links"
Expand Down Expand Up @@ -246,6 +247,18 @@ export class MarkdownRenderable extends Renderable {
this.applyTableOptionsToBlocks()
}

get renderNode(): MarkdownOptions["renderNode"] | undefined {
return this._renderNode
}

set renderNode(value: MarkdownOptions["renderNode"] | undefined) {
if (this._renderNode !== value) {
this._renderNode = value
this.updateBlocks(true)
this.requestRender()
}
}

private getStyle(group: string): StyleDefinition | undefined {
// The solid reconciler applies props via setters in JSX declaration order.
// If `content` is set before `syntaxStyle`, updateBlocks() runs before
Expand Down Expand Up @@ -440,16 +453,73 @@ export class MarkdownRenderable extends Renderable {
})
}

private resolveCodeFiletype(lang: string | undefined): string | undefined {
if (!lang) return undefined
const token = lang.trim().split(/\s+/, 1)[0]
if (!token) return undefined
const normalized = token.toLowerCase()
return extToFiletype(normalized) ?? normalized
}

private getCodeBlockStyle(): StyleDefinition | undefined {
const base = this.getStyle("default")
const raw = this.getStyle("markup.raw.block") ?? this.getStyle("markup.raw")
if (!base && !raw) return undefined
return {
fg: base?.fg ?? raw?.fg,
bg: raw?.bg ?? base?.bg,
bold: base?.bold ?? raw?.bold,
italic: base?.italic ?? raw?.italic,
underline: base?.underline ?? raw?.underline,
dim: base?.dim ?? raw?.dim,
}
}

private applyCodeBlockStyle(renderable: CodeRenderable, token: Tokens.Code): void {
const style = this.getCodeBlockStyle()
if (!style) {
renderable.fg = undefined
renderable.bg = undefined
renderable.attributes = 0
return
}
renderable.fg = style.fg
renderable.bg = style.bg
if (token.lang) {
renderable.attributes = 0
return
}
renderable.attributes = createTextAttributes({
bold: style.bold,
italic: style.italic,
underline: style.underline,
dim: style.dim,
})
}

private createCodeRenderable(token: Tokens.Code, id: string, marginBottom: number = 0): Renderable {
const style = this.getCodeBlockStyle()
const filetype = this.resolveCodeFiletype(token.lang)
const attrs = token.lang
? 0
: createTextAttributes({
bold: style?.bold,
italic: style?.italic,
underline: style?.underline,
dim: style?.dim,
})
return new CodeRenderable(this.ctx, {
id,
content: token.text,
filetype: token.lang || undefined,
filetype,
syntaxStyle: this._syntaxStyle,
conceal: this._concealCode,
drawUnstyledText: !(this._streaming && this._concealCode),
streaming: this._streaming,
treeSitterClient: this._treeSitterClient,
fg: style?.fg,
bg: style?.bg,
attributes: attrs,
width: "100%",
marginBottom,
})
Expand All @@ -467,12 +537,13 @@ export class MarkdownRenderable extends Renderable {

private applyCodeBlockRenderable(renderable: CodeRenderable, token: Tokens.Code, marginBottom: number): void {
renderable.content = token.text
renderable.filetype = token.lang || undefined
renderable.filetype = this.resolveCodeFiletype(token.lang)
renderable.syntaxStyle = this._syntaxStyle
renderable.conceal = this._concealCode
renderable.drawUnstyledText = !(this._streaming && this._concealCode)
renderable.streaming = this._streaming
renderable.marginBottom = marginBottom
this.applyCodeBlockStyle(renderable, token)
}

private shouldRenderSeparately(token: MarkedToken): boolean {
Expand Down
Loading