diff --git a/.github/workflows/build-core.yml b/.github/workflows/build-core.yml index 6464254a2..f8822dfae 100644 --- a/.github/workflows/build-core.yml +++ b/.github/workflows/build-core.yml @@ -27,11 +27,15 @@ jobs: run: bun install - name: Build + env: + BUN_TEST_CONCURRENCY: 1 run: | cd packages/core bun run build - name: Run tests + env: + BUN_TEST_CONCURRENCY: 1 run: | cd packages/core bun run test diff --git a/.github/workflows/build-solid.yml b/.github/workflows/build-solid.yml index a606f2264..ce68c362b 100644 --- a/.github/workflows/build-solid.yml +++ b/.github/workflows/build-solid.yml @@ -27,16 +27,22 @@ jobs: run: bun install - name: Build core + env: + BUN_TEST_CONCURRENCY: 1 run: | cd packages/core bun run build - name: Build + env: + BUN_TEST_CONCURRENCY: 1 run: | cd packages/solid bun run build --ci - name: Run tests + env: + BUN_TEST_CONCURRENCY: 1 run: | cd packages/solid bun run test diff --git a/bun.lock b/bun.lock index f361e5fc8..71ba5d9b4 100644 --- a/bun.lock +++ b/bun.lock @@ -227,6 +227,18 @@ "@opentui/core": ["@opentui/core@workspace:packages/core"], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.58", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zlOdbzzFwNMqBqiWCvzDC/VMs1Gsjgp7Wogoh3bZYYxYyjpCVGcEjP38hY7Db36HezYhPiUnlgg9hK9Tby7hHQ=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.58", "", { "os": "darwin", "cpu": "x64" }, "sha512-GBLbF4rvDToZoLTIV5GTWElXulAdirz52YOrfEOMkpub2DTXaXidqyJTVrNrbc+qq4ca8jzYmTeZTnZ9pPd6NQ=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.58", "", { "os": "linux", "cpu": "arm64" }, "sha512-sVv6PbOxZsZLqSSwJ9R51SQaurvRUzoGJslGWickpxk5QaGHDDxj01H4gR8LfVsRK0HeEdLGvPZLFI7csRn/5A=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.58", "", { "os": "linux", "cpu": "x64" }, "sha512-7fM7V1SKY3iYa1kDMmOHaDskziOzFFnEzkHcfuoLBK4qxJX196GNfH3xi5URQl/JBhavzHqctmv4KvMPOE5PMQ=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.58", "", { "os": "win32", "cpu": "arm64" }, "sha512-oUzb43lv8dx0oijLE2WItciAcE4+c+p78Cl922T3UDyYXUqNP0r3sZDV+Fo3j8XrgpmRWXBj1s7P5igIEGjMUw=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.58", "", { "os": "win32", "cpu": "x64" }, "sha512-oxx55mF6pbC9ARqy4gAmchqFXvCLsEOGQmwMrF5xS8KsBGp5As9tB1ZEYRa6zqiPhAHyp/dAv5OgO6Xvcy2Hew=="], + "@opentui/react": ["@opentui/react@workspace:packages/react"], "@opentui/solid": ["@opentui/solid@workspace:packages/solid"], @@ -247,7 +259,7 @@ "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], - "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="], "@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="], @@ -529,6 +541,8 @@ "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + "bun-types/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], diff --git a/packages/core/src/graphics/protocol.ts b/packages/core/src/graphics/protocol.ts new file mode 100644 index 000000000..46d19d93a --- /dev/null +++ b/packages/core/src/graphics/protocol.ts @@ -0,0 +1,41 @@ +import { env, registerEnvVar } from "../lib/env" +import type { RenderContext } from "../types" + +export type ImageProtocol = "kitty" | "iterm2" | "none" + +export interface GraphicsSupport { + readonly protocol: ImageProtocol +} + +registerEnvVar({ + name: "OTUI_PREFER_KITTY_GRAPHICS", + description: "Force-enable kitty graphics protocol when available.", + type: "boolean", + default: false, +}) + +export function detectGraphicsSupport(): GraphicsSupport { + const termProgram = process.env["TERM_PROGRAM"] ?? "" + const term = process.env["TERM"] ?? "" + if (termProgram === "iTerm.app") { + return { protocol: "iterm2" } + } + if (term.toLowerCase().includes("kitty") || env.OTUI_PREFER_KITTY_GRAPHICS) { + return { protocol: "kitty" } + } + return { protocol: "none" } +} + +export function encodeItermImage(image: Buffer, widthPx: number, heightPx: number): string { + const base64 = image.toString("base64") + return `\u001b]1337;File=inline=1;width=${widthPx}px;height=${heightPx}px;preserveAspectRatio=1:${base64}\u0007` +} + +export function encodeKittyImage(id: number, image: Buffer, widthPx: number, heightPx: number): string { + const base64 = image.toString("base64") + return `\u001b_Gf=100,a=T,s=${widthPx},v=${heightPx},i=${id};${base64}\u001b\\` +} + +export function encodeKittyDelete(id: number): string { + return `\u001b_Ga=d,d=0,i=${id}\u001b\\` +} diff --git a/packages/core/src/renderables/Image.ts b/packages/core/src/renderables/Image.ts new file mode 100644 index 000000000..bf3184edf --- /dev/null +++ b/packages/core/src/renderables/Image.ts @@ -0,0 +1,54 @@ +import { Renderable, type RenderableOptions } from "../Renderable" +import type { RenderContext } from "../types" +import type { OptimizedBuffer } from "../buffer" +import { RGBA, parseColor } from "../lib/RGBA" +import type { GraphicsSupport } from "../graphics/protocol" + +export type ImageFit = "contain" | "cover" | "fill" + +export interface ImageOptions extends RenderableOptions { + src?: string | Buffer + alt?: string + width?: number + height?: number + fit?: ImageFit + pixelWidth?: number + pixelHeight?: number +} + +export class ImageRenderable extends Renderable { + src?: string | Buffer + alt?: string + fit: ImageFit + pixelWidth?: number + pixelHeight?: number + constructor(ctx: RenderContext, options: ImageOptions) { + super(ctx, options) + this.src = options.src + this.alt = options.alt + this.fit = options.fit ?? "contain" + this.width = options.width ?? 0 + this.height = options.height ?? 0 + this.pixelWidth = options.pixelWidth + this.pixelHeight = options.pixelHeight + } + + protected renderSelf(buffer: OptimizedBuffer, _deltaTime: number): void { + const width = Math.max(this.width, 0) + const height = Math.max(this.height, 0) + if (width === 0 || height === 0) return + + // Clear the target area so previous frame contents do not bleed through + buffer.fillRect(this.x, this.y, width, height, RGBA.fromInts(0, 0, 0, 0)) + + const graphics = (this._ctx.graphicsSupport ?? null) as GraphicsSupport | null + const shouldShowFallback = !graphics || graphics.protocol === "none" || !this.src + if (!shouldShowFallback) return + + const fallback = this.alt ?? "" + if (fallback.length === 0) return + + const trimmed = fallback.slice(0, Math.max(width, 1)) + buffer.drawText(trimmed, this.x, this.y, parseColor("#A0A0A0")) + } +} diff --git a/packages/core/src/renderables/index.ts b/packages/core/src/renderables/index.ts index 2abdd9827..607c60847 100644 --- a/packages/core/src/renderables/index.ts +++ b/packages/core/src/renderables/index.ts @@ -6,6 +6,7 @@ export * from "./composition/VRenderable" export * from "./composition/vnode" export * from "./Diff" export * from "./FrameBuffer" +export * from "./Image" export * from "./Input" export * from "./LineNumberRenderable" export * from "./ScrollBar" diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 606fd9d8e..5e14bf1fd 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -14,6 +14,13 @@ import { resolveRenderLib, type RenderLib } from "./zig" import { TerminalConsole, type ConsoleOptions, capture } from "./console" import { MouseParser, type MouseEventType, type RawMouseEvent, type ScrollInfo } from "./lib/parse.mouse" import { Selection } from "./lib/selection" +import { + detectGraphicsSupport, + encodeItermImage, + encodeKittyDelete, + encodeKittyImage, + type GraphicsSupport, +} from "./graphics/protocol" import { EventEmitter } from "events" import { destroySingleton, hasSingleton, singleton } from "./lib/singleton" import { getObjectsInViewport } from "./lib/objects-in-viewport" @@ -32,6 +39,7 @@ import { isPixelResolutionResponse, parsePixelResolution, } from "./lib/terminal-capability-detection" +import { ImageRenderable, type ImageFit } from "./renderables/Image" registerEnvVar({ name: "OTUI_DUMP_CAPTURES", @@ -98,6 +106,10 @@ export type PixelResolution = { width: number height: number } +export type CellMetrics = { + pxPerCellX: number + pxPerCellY: number +} export class MouseEvent { public readonly type: MouseEventType @@ -348,6 +360,25 @@ export class CliRenderer extends EventEmitter implements RenderContext { private idleResolvers: (() => void)[] = [] + private _graphicsSupport: GraphicsSupport = detectGraphicsSupport() + private kittyImageId = 1 + private imageCache: Map< + number, + { + srcKey: string + x: number + y: number + width: number + height: number + fit: ImageFit + pixelWidth?: number + pixelHeight?: number + data: Buffer + kittyId?: number + } + > = new Map() + private cellMetrics: CellMetrics | null = null + private _debugInputs: Array<{ timestamp: string; sequence: string }> = [] private _debugModeEnabled: boolean = env.OTUI_DEBUG @@ -397,6 +428,10 @@ export class CliRenderer extends EventEmitter implements RenderContext { return this._controlState } + public get graphicsSupport(): GraphicsSupport { + return this._graphicsSupport + } + constructor( lib: RenderLib, rendererPtr: Pointer, @@ -503,7 +538,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { // Prevents output from being written to the terminal, useful for debugging if (env.OTUI_NO_NATIVE_RENDER) { - this.renderNative = () => { + this.renderNative = async () => { if (this._splitHeight > 0) { this.flushStdoutCache(this._splitHeight) } @@ -896,7 +931,9 @@ export class CliRenderer extends EventEmitter implements RenderContext { if (isCapabilityResponse(sequence)) { this.lib.processCapabilityResponse(this.rendererPtr, sequence) this._capabilities = this.lib.getTerminalCapabilities(this.rendererPtr) + this.cellMetrics = null this.emit("capabilities", this._capabilities) + this.logDebug(`capabilities updated: ${JSON.stringify(this._capabilities)}`) return true } return false @@ -920,14 +957,30 @@ export class CliRenderer extends EventEmitter implements RenderContext { } this.addInputHandler((sequence: string) => { - if (isPixelResolutionResponse(sequence) && this.waitingForPixelResolution) { + if (isPixelResolutionResponse(sequence)) { const resolution = parsePixelResolution(sequence) if (resolution) { this._resolution = resolution + this._capabilities = { ...(this._capabilities ?? {}), pixelResolution: resolution } + this.cellMetrics = null this.waitingForPixelResolution = false + this.requestRender() + this.emit("pixelResolution", resolution) + this.logDebug(`pixelResolution response: ${JSON.stringify(resolution)}`) + return true } - return true + this.logDebug(`pixelResolution parse failed for sequence: ${JSON.stringify(sequence)}`) + return false + } + + if (env.OTUI_DEBUG) { + const numeric = sequence + .split("") + .map((c) => c.charCodeAt(0)) + .join(",") + this.logDebug(`unhandled sequence: text=${JSON.stringify(sequence)} codes=[${numeric}]`) } + return false }) this.addInputHandler(this.capabilityHandler) @@ -1183,6 +1236,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { this._terminalWidth = width this._terminalHeight = height + this.cellMetrics = null this.queryPixelResolution() this.capturedRenderable = undefined @@ -1591,7 +1645,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { this._console.renderToBuffer(this.nextRenderBuffer) if (!this._isDestroyed) { - this.renderNative() + await this.renderNative() const overallFrameTime = performance.now() - overallStart @@ -1625,7 +1679,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.loop() } - private renderNative(): void { + private async renderNative(): Promise { if (this.renderingNative) { console.error("Rendering called concurrently") throw new Error("Rendering called concurrently") @@ -1640,10 +1694,159 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.renderingNative = true this.lib.render(this.rendererPtr, force) + await this.flushImages() // this.dumpStdoutBuffer(Date.now()) this.renderingNative = false } + public getCellMetrics(): CellMetrics | null { + if (this.cellMetrics) return this.cellMetrics + const cols = this.width + const rows = this.height + const pixelRes = (this._capabilities?.pixelResolution as PixelResolution | undefined) ?? this._resolution + if (!pixelRes || cols <= 0 || rows <= 0) return null + const pxPerCellX = pixelRes.width / cols + const pxPerCellY = pixelRes.height / rows + this.cellMetrics = { pxPerCellX, pxPerCellY } + return this.cellMetrics + } + + private async flushImages(): Promise { + if (this._graphicsSupport.protocol === "none") { + return + } + const images = this.collectImageRenderables(this.root) + const metrics = this.getCellMetrics() + const seen: Set = new Set() + for (const { renderable, x, y } of images) { + if (!renderable.src || !renderable.visible) continue + if ((renderable.pixelWidth !== undefined || renderable.pixelHeight !== undefined) && !metrics) { + continue + } + const width = Math.max(renderable.width, 1) + const height = Math.max(renderable.height, 1) + const srcKey = typeof renderable.src === "string" ? renderable.src : renderable.src.toString("base64") + const previous = this.imageCache.get(renderable.num) + let data: Buffer | null = previous?.data ?? null + const pixelWidth = + renderable.pixelWidth ?? (metrics ? Math.max(1, Math.round(width * metrics.pxPerCellX)) : Math.max(1, width)) + const pixelHeight = + renderable.pixelHeight ?? (metrics ? Math.max(1, Math.round(height * metrics.pxPerCellY)) : Math.max(1, height)) + const changedImage = + !data || + previous?.srcKey !== srcKey || + previous?.width !== width || + previous?.height !== height || + previous?.fit !== renderable.fit || + previous?.pixelWidth !== pixelWidth || + previous?.pixelHeight !== pixelHeight + if (changedImage) { + data = await this.loadImage(renderable.src, pixelWidth, pixelHeight, renderable.fit) + } + if (!data) continue + let kittyId = previous?.kittyId + if (this._graphicsSupport.protocol === "kitty") { + if (kittyId === undefined) { + kittyId = this.kittyImageId++ + } + } + const positionChanged = !previous || previous.x !== x || previous.y !== y + const needsSend = changedImage || positionChanged + if (needsSend && previous?.kittyId !== undefined && this._graphicsSupport.protocol === "kitty") { + this.writeOut(encodeKittyDelete(previous.kittyId)) + } + + this.imageCache.set(renderable.num, { + srcKey, + x, + y, + width, + height, + fit: renderable.fit, + pixelWidth, + pixelHeight, + data, + kittyId, + }) + seen.add(renderable.num) + if (!needsSend) { + continue + } + + let offsetX = 0 + let offsetY = 0 + if (metrics) { + const layoutPxWidth = width * metrics.pxPerCellX + const layoutPxHeight = height * metrics.pxPerCellY + offsetX = Math.max(0, Math.round((layoutPxWidth - pixelWidth) / (2 * metrics.pxPerCellX))) + offsetY = Math.max(0, Math.round((layoutPxHeight - pixelHeight) / (2 * metrics.pxPerCellY))) + } + + const move = `\u001b[${y + offsetY + 1};${x + offsetX + 1}H` + if (this._graphicsSupport.protocol === "iterm2") { + this.writeOut(move + encodeItermImage(data, pixelWidth, pixelHeight)) + } else if (this._graphicsSupport.protocol === "kitty") { + this.writeOut(move + encodeKittyImage(kittyId ?? this.kittyImageId++, data, pixelWidth, pixelHeight)) + } + } + + // Drop cache entries for images no longer in the tree + for (const key of this.imageCache.keys()) { + if (!seen.has(key)) { + const cached = this.imageCache.get(key) + if (cached?.kittyId !== undefined && this._graphicsSupport.protocol === "kitty") { + this.writeOut(encodeKittyDelete(cached.kittyId)) + } + this.imageCache.delete(key) + } + } + } + + private collectImageRenderables(root: Renderable): { renderable: ImageRenderable; x: number; y: number }[] { + const out: { renderable: ImageRenderable; x: number; y: number }[] = [] + const queue: Renderable[] = [root] + while (queue.length > 0) { + const current = queue.shift()! + for (const child of current.getChildren()) { + if (child instanceof ImageRenderable) { + out.push({ renderable: child, x: child.x, y: child.y }) + } + queue.push(child) + } + } + return out + } + + private async loadImage(src: string | Buffer, width: number, height: number, fit: ImageFit): Promise { + try { + const jimpModule: unknown = await import("jimp") + const Jimp = + (jimpModule as { Jimp?: any }).Jimp ?? + (jimpModule as { default?: any }).default ?? + (jimpModule as any) + const input = typeof src === "string" ? src : Buffer.from(src) + const image = await Jimp.read(input) + + switch (fit) { + case "cover": + image.cover(width, height) + break + case "fill": + image.resize(width, height) + break + case "contain": + default: + image.contain(width, height) + break + } + + return await image.getBuffer("image/png") + } catch (error) { + console.error("Failed to load image", error) + return null + } + } + private collectStatSample(frameTime: number): void { this.frameTimes.push(frameTime) if (this.frameTimes.length > this.maxStatSamples) { @@ -1779,6 +1982,8 @@ export class CliRenderer extends EventEmitter implements RenderContext { } } + private logDebug(_message: string): void {} + private notifySelectablesOfSelectionChange(): void { const selectedRenderables: Renderable[] = [] const touchedRenderables: Renderable[] = [] diff --git a/packages/core/src/testing/test-recorder.ts b/packages/core/src/testing/test-recorder.ts index 01913d59a..35c902e36 100644 --- a/packages/core/src/testing/test-recorder.ts +++ b/packages/core/src/testing/test-recorder.ts @@ -16,7 +16,7 @@ export class TestRecorder { private recording: boolean = false private frameNumber: number = 0 private startTime: number = 0 - private originalRenderNative?: () => void + private originalRenderNative?: () => Promise private decoder = new TextDecoder() constructor(renderer: TestRenderer) { @@ -40,9 +40,9 @@ export class TestRecorder { this.originalRenderNative = this.renderer["renderNative"].bind(this.renderer) // Override renderNative to capture frames after each render - this.renderer["renderNative"] = () => { + this.renderer["renderNative"] = async () => { // Call the original renderNative - this.originalRenderNative!() + await this.originalRenderNative!() // Capture the frame after rendering this.captureFrame() diff --git a/packages/core/src/tests/graphics.protocol.test.ts b/packages/core/src/tests/graphics.protocol.test.ts new file mode 100644 index 000000000..2a60859c1 --- /dev/null +++ b/packages/core/src/tests/graphics.protocol.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { detectGraphicsSupport } from "../graphics/protocol" +import { clearEnvCache } from "../lib/env" + +const originalTermProgram = process.env.TERM_PROGRAM +const originalTerm = process.env.TERM +const originalPreferKitty = process.env.OTUI_PREFER_KITTY_GRAPHICS + +function setEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key] + return + } + process.env[key] = value +} + +afterEach(() => { + setEnv("TERM_PROGRAM", originalTermProgram) + setEnv("TERM", originalTerm) + setEnv("OTUI_PREFER_KITTY_GRAPHICS", originalPreferKitty) + clearEnvCache() +}) + +describe("detectGraphicsSupport", () => { + test("detects iTerm2 via TERM_PROGRAM", () => { + setEnv("TERM_PROGRAM", "iTerm.app") + setEnv("TERM", "xterm-256color") + clearEnvCache() + + expect(detectGraphicsSupport().protocol).toBe("iterm2") + }) + + test("detects kitty via TERM", () => { + setEnv("TERM_PROGRAM", "") + setEnv("TERM", "xterm-kitty") + clearEnvCache() + + expect(detectGraphicsSupport().protocol).toBe("kitty") + }) + + test("prefers kitty when override is set", () => { + setEnv("TERM_PROGRAM", "") + setEnv("TERM", "xterm-256color") + setEnv("OTUI_PREFER_KITTY_GRAPHICS", "true") + clearEnvCache() + + expect(detectGraphicsSupport().protocol).toBe("kitty") + }) + + test("falls back to none when no graphics are detected", () => { + setEnv("TERM_PROGRAM", "") + setEnv("TERM", "xterm-256color") + setEnv("OTUI_PREFER_KITTY_GRAPHICS", "false") + clearEnvCache() + + expect(detectGraphicsSupport().protocol).toBe("none") + }) +}) diff --git a/packages/core/src/tests/renderer.control.test.ts b/packages/core/src/tests/renderer.control.test.ts index af074f3a7..daefb5a06 100644 --- a/packages/core/src/tests/renderer.control.test.ts +++ b/packages/core/src/tests/renderer.control.test.ts @@ -133,7 +133,7 @@ test("requestRender() does not trigger when renderer is suspended", async () => // @ts-expect-error - renderNative is private const originalRender = renderer.renderNative.bind(renderer) // @ts-expect-error - renderNative is private - renderer.renderNative = () => { + renderer.renderNative = async () => { renderCalled = true return originalRender() } @@ -156,7 +156,7 @@ test("requestRender() does trigger when renderer is paused", async () => { // @ts-expect-error - renderNative is private const originalRender = renderer.renderNative.bind(renderer) // @ts-expect-error - renderNative is private - renderer.renderNative = () => { + renderer.renderNative = async () => { renderCalled = true return originalRender() } diff --git a/packages/core/src/tests/renderer.images.test.ts b/packages/core/src/tests/renderer.images.test.ts new file mode 100644 index 000000000..b0b9a517b --- /dev/null +++ b/packages/core/src/tests/renderer.images.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { ImageRenderable } from "../renderables/Image" +import { createTestRenderer, type TestRenderer } from "../testing/test-renderer" +import type { GraphicsSupport } from "../graphics/protocol" + +describe("renderer image support", () => { + let renderer: TestRenderer | null = null + let renderOnce: (() => Promise) | null = null + let captureCharFrame: (() => string) | null = null + + afterEach(() => { + if (renderer) { + renderer.destroy() + renderer = null + } + }) + + test("flushes kitty image sequences", async () => { + const setup = await createTestRenderer({ width: 20, height: 10 }) + renderer = setup.renderer + renderOnce = setup.renderOnce + captureCharFrame = setup.captureCharFrame + + const writes: string[] = [] + const testHarness = renderer as unknown as { + writeOut: (chunk: string) => boolean + loadImage: (src: Buffer, width: number, height: number, fit: string) => Promise + _graphicsSupport: GraphicsSupport + } + + testHarness.writeOut = (chunk: string) => { + writes.push(chunk) + return true + } + testHarness.loadImage = async () => Buffer.from("img") + testHarness._graphicsSupport = { protocol: "kitty" } + + const image = new ImageRenderable(renderer, { + src: Buffer.from("img"), + width: 2, + height: 3, + left: 1, + top: 1, + }) + renderer.root.add(image) + + await renderOnce!() + + const expected = `\u001b[2;2H` + `\u001b_Gf=100,a=T,s=2,v=3,i=1;${Buffer.from("img").toString("base64")}\u001b\\` + expect(writes).toContain(expected) + }) + + test("renders alt text when graphics are disabled", async () => { + const setup = await createTestRenderer({ width: 20, height: 6 }) + renderer = setup.renderer + renderOnce = setup.renderOnce + captureCharFrame = setup.captureCharFrame + + const testHarness = renderer as unknown as { _graphicsSupport: GraphicsSupport } + testHarness._graphicsSupport = { protocol: "none" } + + const image = new ImageRenderable(renderer, { alt: "Logo", width: 10, height: 2, left: 0, top: 0 }) + renderer.root.add(image) + + await renderOnce!() + + const frame = captureCharFrame!() + expect(frame).toContain("Logo") + }) +}) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7387701c3..f1146d9a9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3,6 +3,7 @@ import type { EventEmitter } from "events" import type { Selection } from "./lib/selection" import type { Renderable } from "./Renderable" import type { InternalKeyHandler, KeyHandler } from "./lib/KeyHandler" +import type { GraphicsSupport } from "./graphics/protocol" export const TextAttributes = { NONE: 0, @@ -65,6 +66,8 @@ export interface RenderContext extends EventEmitter { clearSelection: () => void startSelection: (renderable: Renderable, x: number, y: number) => void updateSelection: (currentRenderable: Renderable | undefined, x: number, y: number) => void + graphicsSupport?: GraphicsSupport + getCellMetrics?: () => { pxPerCellX: number; pxPerCellY: number } | null } export type Timeout = ReturnType | undefined diff --git a/packages/react/jsx-namespace.d.ts b/packages/react/jsx-namespace.d.ts index 81a85275d..85ca14d65 100644 --- a/packages/react/jsx-namespace.d.ts +++ b/packages/react/jsx-namespace.d.ts @@ -6,6 +6,7 @@ import type { DiffProps, ExtendedIntrinsicElements, InputProps, + ImageProps, LineBreakProps, LineNumberProps, OpenTUIComponents, @@ -42,6 +43,7 @@ export namespace JSX { diff: DiffProps input: InputProps textarea: TextareaProps + image: ImageProps select: SelectProps scrollbox: ScrollBoxProps "ascii-font": AsciiFontProps diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 26f47190d..50f99113d 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -4,6 +4,7 @@ import { CodeRenderable, DiffRenderable, InputRenderable, + ImageRenderable, LineNumberRenderable, ScrollBoxRenderable, SelectRenderable, @@ -32,6 +33,7 @@ export const baseComponents = { "ascii-font": ASCIIFontRenderable, "tab-select": TabSelectRenderable, "line-number": LineNumberRenderable, + image: ImageRenderable, // Text modifiers span: SpanRenderable, diff --git a/packages/react/src/types/components.ts b/packages/react/src/types/components.ts index f5165a0c7..c2c8a19ee 100644 --- a/packages/react/src/types/components.ts +++ b/packages/react/src/types/components.ts @@ -28,6 +28,8 @@ import type { TextNodeRenderable, TextOptions, TextRenderable, + ImageOptions, + ImageRenderable, } from "@opentui/core" import type React from "react" @@ -152,6 +154,8 @@ export type ScrollBoxProps = ComponentProps, Sc export type AsciiFontProps = ComponentProps +export type ImageProps = ComponentProps + export type TabSelectProps = ComponentProps & { focused?: boolean onChange?: (index: number, option: TabSelectOption | null) => void diff --git a/packages/solid/jsx-runtime.d.ts b/packages/solid/jsx-runtime.d.ts index 5aef2bfe9..90818b1a2 100644 --- a/packages/solid/jsx-runtime.d.ts +++ b/packages/solid/jsx-runtime.d.ts @@ -5,6 +5,7 @@ import type { CodeProps, ExtendedIntrinsicElements, InputProps, + ImageProps, OpenTUIComponents, ScrollBoxProps, SelectProps, @@ -32,6 +33,7 @@ declare namespace JSX { scrollbox: ScrollBoxProps code: CodeProps textarea: TextareaProps + image: ImageProps b: SpanProps strong: SpanProps diff --git a/packages/solid/src/elements/hooks.ts b/packages/solid/src/elements/hooks.ts index 0da773881..eda32c84c 100644 --- a/packages/solid/src/elements/hooks.ts +++ b/packages/solid/src/elements/hooks.ts @@ -12,7 +12,7 @@ import { createContext, createSignal, onCleanup, onMount, useContext } from "sol export const RendererContext = createContext() export const useRenderer = () => { - const renderer = useContext(RendererContext) + const renderer = useContext(RendererContext) ?? engine.renderer if (!renderer) { throw new Error("No renderer found") diff --git a/packages/solid/src/elements/index.ts b/packages/solid/src/elements/index.ts index 6d545f385..5f8086c55 100644 --- a/packages/solid/src/elements/index.ts +++ b/packages/solid/src/elements/index.ts @@ -4,6 +4,7 @@ import { CodeRenderable, DiffRenderable, InputRenderable, + ImageRenderable, LineNumberRenderable, ScrollBoxRenderable, SelectRenderable, @@ -88,6 +89,7 @@ export const baseComponents = { code: CodeRenderable, diff: DiffRenderable, line_number: LineNumberRenderable, + image: ImageRenderable, span: SpanRenderable, strong: BoldSpanRenderable, diff --git a/packages/solid/src/reconciler.ts b/packages/solid/src/reconciler.ts index ed5917c74..646d2590f 100644 --- a/packages/solid/src/reconciler.ts +++ b/packages/solid/src/reconciler.ts @@ -15,6 +15,7 @@ import { TextNodeRenderable, TextRenderable, type TextNodeOptions, + engine, } from "@opentui/core" import { useContext } from "solid-js" import { createRenderer } from "./renderer" @@ -169,7 +170,7 @@ export const { createElement(tagName: string): DomNode { log("Creating element:", tagName) const id = getNextId(tagName) - const solidRenderer = useContext(RendererContext) + const solidRenderer = useContext(RendererContext) ?? engine.renderer if (!solidRenderer) { throw new Error("No renderer found") } diff --git a/packages/solid/src/types/elements.ts b/packages/solid/src/types/elements.ts index 4f901f03e..97255a133 100644 --- a/packages/solid/src/types/elements.ts +++ b/packages/solid/src/types/elements.ts @@ -6,6 +6,8 @@ import type { BoxRenderable, CodeOptions, CodeRenderable, + ImageOptions, + ImageRenderable, InputRenderable, InputRenderableOptions, RenderableOptions, @@ -134,6 +136,8 @@ export type SelectProps = ComponentProps +export type ImageProps = ComponentProps + export type TabSelectProps = ComponentProps & { focused?: boolean onChange?: (index: number, option: TabSelectOption | null) => void