Skip to content
Merged
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
54 changes: 53 additions & 1 deletion packages/core/src/buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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, type CapturedSpan, type CapturedLine } from "./types"
import type { TextBufferView } from "./text-buffer-view"
import type { EditorView } from "./editor-view"

Expand Down Expand Up @@ -153,6 +153,58 @@ export class OptimizedBuffer {
return outputBuffer.slice(0, bytesWritten)
}

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

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

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

// Check if this cell continues the current span
if (
currentSpan &&
currentSpan.fg.equals(cellFg) &&
currentSpan.bg.equals(cellBg) &&
currentSpan.attributes === cellAttrs
) {
currentSpan.text += cellChar
currentSpan.width += 1
} else {
// Start a new span
if (currentSpan) {
spans.push(currentSpan)
}
currentSpan = {
text: cellChar,
fg: cellFg,
bg: cellBg,
attributes: cellAttrs,
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
5 changes: 5 additions & 0 deletions packages/core/src/lib/RGBA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export class RGBA {
toString() {
return `rgba(${this.r.toFixed(2)}, ${this.g.toFixed(2)}, ${this.b.toFixed(2)}, ${this.a.toFixed(2)})`
}

equals(other?: RGBA): boolean {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Probably needs some epsilon for comparing floats moving forward, but fine for now.

if (!other) return false
return this.r === other.r && this.g === other.g && this.b === other.b && this.a === other.a
}
}

export type ColorInput = string | RGBA
Expand Down
147 changes: 147 additions & 0 deletions packages/core/src/testing/capture-spans.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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 { TextAttributes, type CapturedFrame } from "../types"
import { RGBA } from "../lib"

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

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.r).toBe(1)
expect(redSpan!.fg.g).toBe(0)
expect(redSpan!.fg.b).toBe(0)
})

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.g === 1 && s.bg.r === 0 && s.bg.b === 0)

expect(greenSpan).toBeDefined()
})

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

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

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!.attributes & TextAttributes.BOLD).toBeTruthy()
expect(styledSpan!.attributes & TextAttributes.ITALIC).toBeTruthy()
expect(styledSpan!.attributes & TextAttributes.UNDERLINE).toBeTruthy()
expect(styledSpan!.attributes & TextAttributes.DIM).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.r === 1 && s.fg.g === 0)).toBe(true)
expect(allSpans.some((s) => s.fg.g === 1 && s.fg.r === 0)).toBe(true)
})
})
13 changes: 13 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 { CapturedFrame } 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: () => CapturedFrame
resize: (width: number, height: number) => void
}> {
process.env.OTUI_USE_CONSOLE = "false"
Expand Down Expand Up @@ -59,6 +61,17 @@ 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],
lines,
}
},
resize: (width: number, height: number) => {
//@ts-expect-error - this is a test renderer
renderer.processResize(width, height)
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,22 @@ export interface LineInfoProvider {
get virtualLineCount(): number
get scrollY(): number
}

export interface CapturedSpan {
text: string
fg: RGBA
bg: RGBA
attributes: number
width: number
}

export interface CapturedLine {
spans: CapturedSpan[]
}

export interface CapturedFrame {
cols: number
rows: number
cursor: [number, number]
lines: CapturedLine[]
}
Loading