diff --git a/packages/core/src/buffer.ts b/packages/core/src/buffer.ts index cef9d184d..87b7fd7c0 100644 --- a/packages/core/src/buffer.ts +++ b/packages/core/src/buffer.ts @@ -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" @@ -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) diff --git a/packages/core/src/lib/RGBA.ts b/packages/core/src/lib/RGBA.ts index a9ad9dd85..1dae109a9 100644 --- a/packages/core/src/lib/RGBA.ts +++ b/packages/core/src/lib/RGBA.ts @@ -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 { + 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 diff --git a/packages/core/src/testing/capture-spans.test.ts b/packages/core/src/testing/capture-spans.test.ts new file mode 100644 index 000000000..6fb787681 --- /dev/null +++ b/packages/core/src/testing/capture-spans.test.ts @@ -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 + 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) + }) +}) diff --git a/packages/core/src/testing/test-renderer.ts b/packages/core/src/testing/test-renderer.ts index e45ecfb47..5c56a5eef 100644 --- a/packages/core/src/testing/test-renderer.ts +++ b/packages/core/src/testing/test-renderer.ts @@ -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 @@ -22,6 +23,7 @@ export async function createTestRenderer(options: TestRendererOptions): Promise< mockMouse: MockMouse renderOnce: () => Promise captureCharFrame: () => string + captureSpans: () => CapturedFrame resize: (width: number, height: number) => void }> { process.env.OTUI_USE_CONSOLE = "false" @@ -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) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e15c692a6..201335c5c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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[] +}