Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
76 changes: 75 additions & 1 deletion packages/core/src/buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,28 @@ import { RGBA } from "./lib"
import { resolveRenderLib, type RenderLib } from "./zig"
import { type Pointer, toArrayBuffer, ptr } from "bun:ffi"
import { type BorderStyle, type BorderSides, BorderCharArrays, parseBorderStyle } from "./lib"
import { type WidthMethod } from "./types"
import {
type WidthMethod,
TextAttributes,
VTermStyleFlags,
type VTermSpan,
type VTermLine,
type VTermData,
} from "./types"
import type { TextBufferView } from "./text-buffer-view"
import type { EditorView } from "./editor-view"

function textAttrsToVTermFlags(attr: number): number {
let flags = 0
if (attr & TextAttributes.BOLD) flags |= VTermStyleFlags.BOLD
if (attr & TextAttributes.DIM) flags |= VTermStyleFlags.FAINT
if (attr & TextAttributes.ITALIC) flags |= VTermStyleFlags.ITALIC
if (attr & TextAttributes.UNDERLINE) flags |= VTermStyleFlags.UNDERLINE
if (attr & TextAttributes.INVERSE) flags |= VTermStyleFlags.INVERSE
if (attr & TextAttributes.STRIKETHROUGH) flags |= VTermStyleFlags.STRIKETHROUGH
return flags
}

// Pack drawing options into a single u32
// bits 0-3: borderSides, bit 4: shouldFill, bits 5-6: titleAlignment
function packDrawOptions(
Expand Down Expand Up @@ -153,6 +171,62 @@ export class OptimizedBuffer {
return outputBuffer.slice(0, bytesWritten)
}

public getSpanLines(): VTermLine[] {
this.guard()
const { char, fg, bg, attributes } = this.buffers
const lines: VTermLine[] = []

const rgbaToHex = (r: number, g: number, b: number, a: number): string | null => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a rgbToHex method already in RGBA.ts and I wouldn't redefine such a method inline.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, using RGBA class and rgbToHex from lib now.

if (a === 0) return null
const toHex = (v: number) =>
Math.round(v * 255)
.toString(16)
.padStart(2, "0")
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}

for (let y = 0; y < this._height; y++) {
const spans: VTermSpan[] = []
let currentSpan: VTermSpan | null = null

for (let x = 0; x < this._width; x++) {
const i = y * this._width + x
const cp = char[i]
const cellFg = rgbaToHex(fg[i * 4], fg[i * 4 + 1], fg[i * 4 + 2], fg[i * 4 + 3])
const cellBg = rgbaToHex(bg[i * 4], bg[i * 4 + 1], bg[i * 4 + 2], bg[i * 4 + 3])
const cellFlags = textAttrsToVTermFlags(attributes[i] & 0xff)
const cellChar = cp > 0 ? String.fromCodePoint(cp) : " "

// Check if this cell continues the current span
if (currentSpan && currentSpan.fg === cellFg && currentSpan.bg === cellBg && currentSpan.flags === cellFlags) {
currentSpan.text += cellChar
currentSpan.width += 1
} else {
// Start a new span
if (currentSpan) {
spans.push(currentSpan)
}
currentSpan = {
text: cellChar,
fg: cellFg,
bg: cellBg,
flags: cellFlags,
width: 1,
}
}
}

// Push the last span
if (currentSpan) {
spans.push(currentSpan)
}

lines.push({ spans })
}

return lines
}

public clear(bg: RGBA = RGBA.fromValues(0, 0, 0, 1)): void {
this.guard()
this.lib.bufferClear(this.bufferPtr, bg)
Expand Down
145 changes: 145 additions & 0 deletions packages/core/src/testing/capture-spans.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { createTestRenderer, type TestRenderer } from "./test-renderer"
import { TextRenderable } from "../renderables/Text"
import { BoxRenderable } from "../renderables/Box"
import { VTermStyleFlags, TextAttributes, type VTermData } from "../types"
import { RGBA } from "../lib"

describe("captureSpans", () => {
let renderer: TestRenderer
let renderOnce: () => Promise<void>
let captureSpans: () => VTermData

beforeEach(async () => {
const setup = await createTestRenderer({ width: 40, height: 10 })
renderer = setup.renderer
renderOnce = setup.renderOnce
captureSpans = setup.captureSpans
})

afterEach(() => {
renderer.destroy()
})

test("returns correct dimensions and line count", async () => {
await renderOnce()
const data = captureSpans()

expect(data.cols).toBe(40)
expect(data.rows).toBe(10)
expect(data.lines.length).toBe(10)
})

test("captures text content in spans", async () => {
const text = new TextRenderable(renderer, { content: "Hello World" })
renderer.root.add(text)
await renderOnce()

const data = captureSpans()
const firstLine = data.lines[0]
const textContent = firstLine.spans.map((s) => s.text).join("")

expect(textContent).toContain("Hello World")
})

test("groups consecutive cells with same styling into single span", async () => {
const text = new TextRenderable(renderer, { content: "AAAA" })
renderer.root.add(text)
await renderOnce()

const data = captureSpans()
const firstLine = data.lines[0]
const aaaSpan = firstLine.spans.find((s) => s.text.includes("AAAA"))

expect(aaaSpan).toBeDefined()
expect(aaaSpan!.width).toBeGreaterThanOrEqual(4)
})

test("captures foreground color", async () => {
const text = new TextRenderable(renderer, {
content: "Red Text",
fg: RGBA.fromHex("#ff0000"),
})
renderer.root.add(text)
await renderOnce()

const data = captureSpans()
const firstLine = data.lines[0]
const redSpan = firstLine.spans.find((s) => s.text.includes("Red"))

expect(redSpan).toBeDefined()
expect(redSpan!.fg).toBe("#ff0000")
})

test("captures background color", async () => {
const box = new BoxRenderable(renderer, {
width: 10,
height: 3,
backgroundColor: RGBA.fromHex("#00ff00"),
})
renderer.root.add(box)
await renderOnce()

const data = captureSpans()
const secondLine = data.lines[1]
const greenSpan = secondLine.spans.find((s) => s.bg === "#00ff00")

expect(greenSpan).toBeDefined()
})

test("returns null for transparent colors", async () => {
await renderOnce()

const data = captureSpans()
const firstLine = data.lines[0]
const transparentSpan = firstLine.spans.find((s) => s.bg === null)

expect(transparentSpan).toBeDefined()
})

test("captures text attributes", async () => {
const text = new TextRenderable(renderer, {
content: "Styled",
attributes: TextAttributes.BOLD | TextAttributes.ITALIC | TextAttributes.UNDERLINE | TextAttributes.DIM,
})
renderer.root.add(text)
await renderOnce()

const data = captureSpans()
const firstLine = data.lines[0]
const styledSpan = firstLine.spans.find((s) => s.text.includes("Styled"))

expect(styledSpan).toBeDefined()
expect(styledSpan!.flags & VTermStyleFlags.BOLD).toBeTruthy()
expect(styledSpan!.flags & VTermStyleFlags.ITALIC).toBeTruthy()
expect(styledSpan!.flags & VTermStyleFlags.UNDERLINE).toBeTruthy()
expect(styledSpan!.flags & VTermStyleFlags.FAINT).toBeTruthy()
})

test("includes cursor position", async () => {
await renderOnce()
const data = captureSpans()

expect(data.cursor).toEqual([expect.any(Number), expect.any(Number)])
})

test("splits spans when styling changes", async () => {
const text1 = new TextRenderable(renderer, {
content: "AAA",
fg: RGBA.fromHex("#ff0000"),
})
const text2 = new TextRenderable(renderer, {
content: "BBB",
fg: RGBA.fromHex("#00ff00"),
})
renderer.root.add(text1)
renderer.root.add(text2)
await renderOnce()

const data = captureSpans()
const allSpans = data.lines.flatMap((l) => l.spans)

expect(allSpans.some((s) => s.fg === "#ff0000")).toBe(true)
expect(allSpans.some((s) => s.fg === "#00ff00")).toBe(true)
})
})
15 changes: 15 additions & 0 deletions packages/core/src/testing/test-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CliRenderer, type CliRendererConfig } from "../renderer"
import { resolveRenderLib } from "../zig"
import { createMockKeys } from "./mock-keys"
import { createMockMouse } from "./mock-mouse"
import type { VTermData } from "../types"

export interface TestRendererOptions extends CliRendererConfig {
width?: number
Expand All @@ -22,6 +23,7 @@ export async function createTestRenderer(options: TestRendererOptions): Promise<
mockMouse: MockMouse
renderOnce: () => Promise<void>
captureCharFrame: () => string
captureSpans: () => VTermData
resize: (width: number, height: number) => void
}> {
process.env.OTUI_USE_CONSOLE = "false"
Expand Down Expand Up @@ -59,6 +61,19 @@ export async function createTestRenderer(options: TestRendererOptions): Promise<
const frameBytes = currentBuffer.getRealCharBytes(true)
return decoder.decode(frameBytes)
},
captureSpans: () => {
const currentBuffer = renderer.currentRenderBuffer
const lines = currentBuffer.getSpanLines()
const cursorState = renderer.getCursorState()
return {
cols: currentBuffer.width,
rows: currentBuffer.height,
cursor: [cursorState.x, cursorState.y] as [number, number],
offset: 0,
totalLines: lines.length,
lines,
}
},
resize: (width: number, height: number) => {
//@ts-expect-error - this is a test renderer
renderer.processResize(width, height)
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,33 @@ export interface LineInfoProvider {
get virtualLineCount(): number
get scrollY(): number
}

export const VTermStyleFlags = {
BOLD: 1,
ITALIC: 2,
UNDERLINE: 4,
STRIKETHROUGH: 8,
INVERSE: 16,
FAINT: 32,
} as const

export interface VTermSpan {
text: string
fg: string | null
bg: string | null
flags: number
width: number
}

export interface VTermLine {
spans: VTermSpan[]
}

export interface VTermData {
cols: number
rows: number
cursor: [number, number]
offset: number
totalLines: number
lines: VTermLine[]
}
Loading