diff --git a/packages/core/src/examples/index.ts b/packages/core/src/examples/index.ts index 51f0eec05..210eccdd0 100644 --- a/packages/core/src/examples/index.ts +++ b/packages/core/src/examples/index.ts @@ -49,6 +49,8 @@ import * as liveStateExample from "./live-state-demo" import * as fullUnicodeExample from "./full-unicode-demo" import * as textNodeDemo from "./text-node-demo" import { getKeyHandler } from "../lib/KeyHandler" +import { setRenderLibPath } from "../zig" +import fs from "node:fs" import { setupCommonDemoKeys } from "./lib/standalone-keys" interface Example { @@ -508,6 +510,17 @@ class ExampleSelector { } } +// Prefer local freshly-built native lib during development +try { + const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : process.arch + const os = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux" + const ext = process.platform === "darwin" ? "dylib" : process.platform === "win32" ? "dll" : "so" + const url = new URL(`../zig/lib/${arch}-${os}/libopentui.${ext}`, import.meta.url) + if (fs.existsSync(url.pathname)) { + setRenderLibPath(url.pathname) + } +} catch {} + const renderer = await createCliRenderer({ exitOnCtrlC: false, targetFps: 60, diff --git a/packages/core/src/examples/simple-terminal-demo.ts b/packages/core/src/examples/simple-terminal-demo.ts new file mode 100644 index 000000000..7c1490b4c --- /dev/null +++ b/packages/core/src/examples/simple-terminal-demo.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env bun + +/** + * Simple Interactive Terminal Demo + * + * A minimal terminal emulator using libvterm that provides a fully interactive shell. + * You can type commands and interact with it like a normal terminal. + */ + +import { createCliRenderer, BoxRenderable } from "../index" +import { TerminalRenderer } from "../renderables/TerminalRenderer" +import { setRenderLibPath } from "../zig" +import { parseKeypress } from "../lib/parse.keypress" + +async function main() { + try { + const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : process.arch + const os = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux" + const ext = process.platform === "darwin" ? "dylib" : process.platform === "win32" ? "dll" : "so" + const libPath = new URL(`../zig/lib/${arch}-${os}/libopentui.${ext}`, import.meta.url).pathname + setRenderLibPath(libPath) + } catch (e) { + console.warn("⚠️ Failed to load local libopentui library", e) + } + + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + targetFps: 30, + }) + + renderer.useMouse = true + renderer.setBackgroundColor("#001122") + + renderer.console.show() + + const container = new BoxRenderable(renderer, { + position: "absolute", + left: 2, + top: 1, + width: renderer.width - 4, + height: renderer.height - 2, + border: true, + borderStyle: "single", + borderColor: "#00AAFF", + title: "Terminal (libvterm)", + backgroundColor: "#000000", + }) + + const terminalCols = Math.max(10, container.width - 2) + const terminalRows = Math.max(5, container.height - 2) + + const terminal = new TerminalRenderer(renderer, { + width: container.width - 2, // Account for container border + height: container.height - 2, // Account for container border + cols: terminalCols, + rows: terminalRows, + shell: "bash", + backgroundColor: "#000000", + autoFocus: true, + }) + + container.add(terminal) + renderer.root.add(container) + + renderer.on("resize", () => { + container.width = renderer.width - 4 + container.height = renderer.height - 2 + terminal.width = container.width - 2 + terminal.height = container.height - 2 + terminal.cols = Math.max(10, container.width - 2) + terminal.rows = Math.max(5, container.height - 2) + }) + + renderer.on("key", (data: Buffer) => { + const key = parseKeypress(data) + + if (key.raw === "\u0003") return + + terminal.handleKeyPress(key) + }) + + renderer.focusRenderable(terminal) + + renderer.start() + + console.log("🚀 Simple Terminal Demo Started!") + console.log("💡 Type commands normally. Press Ctrl+C to exit.") + console.log(`📊 Terminal: ${terminalCols}x${terminalRows} characters`) + console.log(`🔧 Backend: ${terminal.hasLibvtermSupport ? "libvterm" : "basic PTY"}`) + console.log("📋 Copy/Paste:") + console.log(" - Copy: Ctrl+Shift+C") + console.log(" - Paste: Ctrl+Shift+V") +} + +if (import.meta.main) { + main().catch(console.error) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 73ec45ad7..b7bf8cf65 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,7 @@ export * from "./animation/Timeline" export * from "./lib" export * from "./renderer" export * from "./renderables" +export * from "./renderables/TerminalRenderer" export * from "./zig" export * from "./console" export * as Yoga from "yoga-layout" diff --git a/packages/core/src/lib/clipboard.ts b/packages/core/src/lib/clipboard.ts new file mode 100644 index 000000000..d8e9d4eaa --- /dev/null +++ b/packages/core/src/lib/clipboard.ts @@ -0,0 +1,71 @@ +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +type Command = string[] + +enum Platform { + Mac = "darwin", + Windows = "win32", +} + +function runCommand(cmd: Command, input?: string): { success: boolean; output?: string } { + try { + const result = Bun.spawnSync({ + cmd, + stdin: input ? encoder.encode(input) : undefined, + stdout: "pipe", + stderr: "inherit", + }) + + if (result.success) { + const out = result.stdout ? decoder.decode(result.stdout) : "" + return { success: true, output: out } + } + } catch (error) { + // Command not available; ignore + } + + return { success: false } +} + +function platformCopyCommands(): Command[] { + switch (process.platform) { + case Platform.Mac: + return [["pbcopy"]] + case Platform.Windows: + return [["cmd", "/c", "clip"]] + default: + return [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]] + } +} + +function platformPasteCommands(): Command[] { + switch (process.platform) { + case Platform.Mac: + return [["pbpaste"]] + case Platform.Windows: + return [["powershell", "-NoProfile", "-Command", "Get-Clipboard"]] + default: + return [["wl-paste"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "--clipboard", "--output"]] + } +} + +export function copyTextToClipboard(text: string): boolean { + for (const cmd of platformCopyCommands()) { + const result = runCommand(cmd, text) + if (result.success) { + return true + } + } + return false +} + +export function pasteTextFromClipboard(): string { + for (const cmd of platformPasteCommands()) { + const result = runCommand(cmd) + if (result.success && typeof result.output === "string") { + return result.output.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + } + } + return "" +} diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts new file mode 100644 index 000000000..e416ced78 --- /dev/null +++ b/packages/core/src/pty.ts @@ -0,0 +1,87 @@ +import { resolveRenderLib, type RenderLib } from "./zig" +import type { Pointer } from "bun:ffi" +import { OptimizedBuffer } from "./buffer" + +export class NativePtySession { + private lib: RenderLib + private ptr: Pointer | null + public cols: number + public rows: number + private destroyed: boolean = false + + constructor(ptr: Pointer, cols: number, rows: number, lib: RenderLib) { + this.ptr = ptr + this.cols = cols + this.rows = rows + this.lib = lib + } + + static create(cols: number, rows: number): NativePtySession { + const lib = resolveRenderLib() + const sess = lib.terminalSessionCreate(cols, rows) + if (!sess) throw new Error(`Failed to create native PTY session (cols=${cols}, rows=${rows})`) + return new NativePtySession(sess, cols, rows, lib) + } + + write(data: string | Uint8Array): number { + if (this.destroyed || !this.ptr) { + console.warn("Attempted to write to destroyed PTY session") + return 0 + } + try { + const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data + return this.lib.terminalSessionWrite(this.ptr, bytes) + } catch (e) { + console.error("PTY write error:", e) + return 0 + } + } + + resize(cols: number, rows: number): void { + if (this.destroyed || !this.ptr) { + console.warn("Attempted to resize destroyed PTY session") + return + } + this.cols = cols + this.rows = rows + try { + this.lib.terminalSessionResize(this.ptr, cols, rows) + } catch (e) { + console.error("PTY resize error:", e) + } + } + + tick(): number { + if (this.destroyed || !this.ptr) return 0 + try { + return this.lib.terminalSessionTick(this.ptr) + } catch (e) { + console.error("PTY tick error:", e) + return 0 + } + } + + render(target: OptimizedBuffer, x: number, y: number): void { + if (this.destroyed || !this.ptr) return + try { + this.lib.terminalSessionRender(this.ptr, target.ptr, x, y) + } catch (e) { + console.error("PTY render error:", e) + } + } + + destroy(): void { + if (this.destroyed || !this.ptr) return + this.destroyed = true + try { + this.lib.terminalSessionDestroy(this.ptr) + } catch (e) { + console.error("PTY destroy error:", e) + } + this.ptr = null + } +} + +export function createPtySession(cols: number, rows: number): NativePtySession { + return NativePtySession.create(cols, rows) +} diff --git a/packages/core/src/renderables/TerminalRenderer.ts b/packages/core/src/renderables/TerminalRenderer.ts new file mode 100644 index 000000000..3dadac93b --- /dev/null +++ b/packages/core/src/renderables/TerminalRenderer.ts @@ -0,0 +1,554 @@ +import { type RenderableOptions, Renderable } from "../Renderable" +import type { OptimizedBuffer } from "../buffer" +import type { RenderContext } from "../types" +import type { MouseEvent } from "../renderer" +import { RGBA, parseColor } from "../lib/RGBA" +import { type ParsedKey } from "../lib/parse.keypress" +import { resolveRenderLib } from "../zig" +import { type Selection as GlobalSelection, convertGlobalToLocalSelection } from "../lib/selection" +import { copyTextToClipboard, pasteTextFromClipboard } from "../lib/clipboard" + +type SelectionRect = { + startRow: number + endRow: number + startCol: number + endCol: number +} + +export interface CommandResult { + exitCode: number + stdout: string + stderr: string + duration: number +} + +export interface TerminalRendererOptions extends RenderableOptions { + cols?: number + rows?: number + shell?: "bash" | "zsh" | "fish" | "sh" | "cmd" | "powershell" + cwd?: string + env?: Record + backgroundColor?: string | RGBA + autoFocus?: boolean + selectionForegroundColor?: string | RGBA + selectionBackgroundColor?: string | RGBA +} + +/** + * TerminalRenderer - A full-featured terminal emulator using libvterm + * + * This class provides a complete terminal emulator that can be embedded + * in OpenTUI applications as a regular UI component. It uses libvterm + * for proper ANSI sequence processing and terminal emulation. + */ +export class TerminalRenderer extends Renderable { + private ptySession: any + private _cols: number + private _rows: number + private _shell: "bash" | "zsh" | "fish" | "sh" | "cmd" | "powershell" + private _cwd?: string + private _env?: Record + private _backgroundColor: RGBA + private _hasLibvtermSupport: boolean + private _selectionFg: RGBA + private _selectionBg: RGBA + private selectionRect: SelectionRect | null = null + + protected _defaultOptions = { + cols: 80, + rows: 24, + shell: "bash" as const, + backgroundColor: "#000000", + autoFocus: true, + selectionForegroundColor: "#000000", + selectionBackgroundColor: "#6d9df1", + } + + private static textDecoder = new TextDecoder() + private static textEncoder = new TextEncoder() + + constructor(ctx: RenderContext, options: TerminalRendererOptions) { + super(ctx, options) + + this._focusable = true + this.selectable = true + + this._cols = options.cols ?? this._defaultOptions.cols + this._rows = options.rows ?? this._defaultOptions.rows + this._shell = options.shell ?? this._defaultOptions.shell + this._cwd = options.cwd + this._env = options.env + this._backgroundColor = parseColor(options.backgroundColor ?? this._defaultOptions.backgroundColor) + this._selectionFg = parseColor(options.selectionForegroundColor ?? this._defaultOptions.selectionForegroundColor) + this._selectionBg = parseColor(options.selectionBackgroundColor ?? this._defaultOptions.selectionBackgroundColor) + + this._hasLibvtermSupport = this.checkLibvtermSupport() + + this.initializeLibvterm().catch((error) => { + console.error("Failed to initialize terminal:", error) + }) + + this.live = true + + this.onSizeChange = () => { + this.onResize(this.width, this.height) + } + + if (options.autoFocus ?? this._defaultOptions.autoFocus) { + this.ctx.focusRenderable(this) + } + } + + private checkLibvtermSupport(): boolean { + try { + const renderLib = resolveRenderLib() + return renderLib && typeof renderLib.libvtermRendererCreate === "function" + } catch { + return false + } + } + + private async initializeLibvterm(): Promise { + try { + const renderLib = resolveRenderLib() + + this.ptySession = renderLib.terminalSessionCreate(this._cols, this._rows) + if (!this.ptySession) { + throw new Error("Failed to create PTY session") + } + + setTimeout(() => { + if (this.width > 0 && this.height > 0) { + this.onResize(this.width, this.height) + } + }, 100) + + setTimeout(() => { + if (this.ptySession) { + for (let i = 0; i < 5; i++) { + renderLib.terminalSessionTick(this.ptySession) + } + this.requestRender() + } + }, 200) + } catch (error) { + throw new Error(`Failed to initialize terminal: ${error}`) + } + } + + // Terminal interaction methods + public write(data: string | Uint8Array): number { + try { + const renderLib = resolveRenderLib() + const dataBuffer = typeof data === "string" ? TerminalRenderer.textEncoder.encode(data) : data + + this.clearSelectionHighlight() + + if (this.ptySession) { + return renderLib.terminalSessionWrite(this.ptySession, dataBuffer) + } else { + return 0 + } + } catch (error) { + console.error("Failed to write to terminal:", error) + return 0 + } + } + + public get isRunning(): boolean { + return !!this.ptySession + } + + // Property getters and setters + public get cols(): number { + return this._cols + } + + public set cols(value: number) { + if (value !== this._cols) { + this._cols = value + this.resizePty() + this.applySelectionRect(this.selectionRect) + } + } + + public get rows(): number { + return this._rows + } + + public set rows(value: number) { + if (value !== this._rows) { + this._rows = value + this.resizePty() + this.applySelectionRect(this.selectionRect) + } + } + + public get backgroundColor(): RGBA { + return this._backgroundColor + } + + public set backgroundColor(value: RGBA | string) { + this._backgroundColor = typeof value === "string" ? parseColor(value) : value + } + + public get hasLibvtermSupport(): boolean { + return this._hasLibvtermSupport + } + + public get focused(): boolean { + return this._focused + } + + private resizePty(): void { + try { + const renderLib = resolveRenderLib() + + if (this.ptySession) { + renderLib.terminalSessionResize(this.ptySession, this._cols, this._rows) + } + } catch (error) { + console.error("Failed to resize terminal:", error) + } + } + + protected onResize(width: number, height: number): void { + const contentWidth = Math.max(1, width) + const contentHeight = Math.max(1, height) + + // Assume each character is 1 unit wide/tall for now + // In a real implementation, this would depend on font metrics + const newCols = Math.max(1, contentWidth) + const newRows = Math.max(1, contentHeight) + + if (newCols !== this._cols || newRows !== this._rows) { + this._cols = newCols + this._rows = newRows + this.resizePty() + } + } + + protected onUpdate(deltaTime: number): void { + try { + const renderLib = resolveRenderLib() + + // Tick PTY session to read shell output and process it + // The terminal session internally uses libvterm when available + if (this.ptySession) { + const bytesRead = renderLib.terminalSessionTick(this.ptySession) + if (bytesRead > 0) { + this.requestRender() + } + } + } catch (error) { + console.error("Failed to update terminal:", error) + } + } + + public render(buffer: OptimizedBuffer, deltaTime: number): void { + this.onUpdate(deltaTime) + super.render(buffer, deltaTime) + } + + // Override methods to prevent adding children to terminal + public add(obj: any, index?: number): number { + return -1 + } + + public insertBefore(obj: any, anchor?: any): number { + return -1 + } + + public appendChild(obj: any): number { + return -1 + } + + public removeChild(obj: any): void {} + + protected renderSelf(buffer: OptimizedBuffer): void { + const { x, y, width, height } = this.getScissorRect() + + if (width <= 0 || height <= 0) { + return + } + + buffer.pushScissorRect(x, y, width, height) + + try { + buffer.fillRect(x, y, width, height, this._backgroundColor) + this.renderTerminalContent(buffer) + } finally { + buffer.popScissorRect() + } + } + + protected onMouseEvent(event: MouseEvent): void { + super.onMouseEvent(event) + } + + public handleKeyPress(key: ParsedKey): boolean { + if (!this.ptySession) { + return false + } + + try { + if (this.tryHandleClipboardShortcut(key)) { + return true + } + + if (key.raw) { + this.write(key.raw) + return true + } else if (key.sequence) { + this.write(key.sequence) + return true + } + + return false + } catch (error) { + console.error("Failed to handle key press:", error) + return false + } + } + + private tryHandleClipboardShortcut(key: ParsedKey): boolean { + const isMac = process.platform === "darwin" + + // On macOS, since Cmd+C/V are intercepted by the system, + // we can use Ctrl+Shift+C/V like other terminals (iTerm2, Terminal.app with settings) + // Or use ESC+c/v as an alternative + const isCopyShortcut = + (key.ctrl && key.shift && key.name === "c") || // Works on all platforms + (key.meta && key.name === "c") || // In case meta works + key.sequence === "\x1bc" // ESC+c as alternative on Mac + + const isPasteShortcut = + (key.ctrl && key.shift && key.name === "v") || // Works on all platforms + (key.meta && key.name === "v") || // In case meta works + (key.shift && key.name === "insert") || + key.sequence === "\x1bv" // ESC+v as alternative on Mac + + if (isCopyShortcut) { + const selected = this.getSelectedText() + if (selected) { + copyTextToClipboard(selected) + } + // Always return true for copy shortcuts to prevent the raw sequence from being written + return true + } + + if (isPasteShortcut) { + const text = pasteTextFromClipboard() + if (text) { + this.clearSelectionHighlight() + this.write(text) + return true + } + } + + return false + } + + private renderTerminalContent(buffer: OptimizedBuffer): void { + const { x, y, width, height } = this.getScissorRect() + const contentX = x + const contentY = y + const contentWidth = width + const contentHeight = height + + if (contentWidth <= 0 || contentHeight <= 0) { + return + } + + try { + const renderLib = resolveRenderLib() + + if (this.ptySession) { + renderLib.terminalSessionRender(this.ptySession, buffer.ptr, contentX, contentY) + } + } catch (error) { + console.error("Failed to render terminal content:", error) + + buffer.drawText("Terminal Error", contentX, contentY, RGBA.fromInts(255, 0, 0, 255), undefined, 0) + } + } + + public shouldStartSelection(x: number, y: number): boolean { + const { contentX, contentY, contentWidth, contentHeight } = this.getContentMetrics() + const withinX = x >= contentX && x < contentX + contentWidth + const withinY = y >= contentY && y < contentY + contentHeight + return withinX && withinY + } + + public onSelectionChanged(selection: GlobalSelection | null): boolean { + const changed = this.updateSelectionFromGlobal(selection) + if (changed) { + this.requestRender() + } + return this.hasSelection() + } + + public hasSelection(): boolean { + return this.selectionRect !== null + } + + public getSelectedText(): string { + if (!this.selectionRect || !this.ptySession) return "" + + const { startRow, endRow, startCol, endCol } = this.selectionRect + const rows = endRow - startRow + const cols = endCol - startCol + if (rows <= 0 || cols <= 0) return "" + + const renderLib = resolveRenderLib() + if (!(renderLib as any).terminalSessionCopySelection) { + return "" + } + + const maxBytesPerRow = cols * 24 + 1 + const bufferSize = rows * maxBytesPerRow + const buffer = new Uint8Array(bufferSize) + const length = (renderLib as any).terminalSessionCopySelection( + this.ptySession, + startRow, + startCol, + endRow, + endCol, + buffer, + bufferSize, + ) + + const lengthNum = typeof length === "bigint" ? Number(length) : length + if (lengthNum === 0) return "" + const text = TerminalRenderer.textDecoder.decode(buffer.slice(0, lengthNum)) + return text + } + + protected getScissorRect(): { x: number; y: number; width: number; height: number } { + const computedWidth = this.width + const computedHeight = this.height + + return { + x: this.x, + y: this.y, + width: computedWidth > 0 ? computedWidth : this._cols, + height: computedHeight > 0 ? computedHeight : this._rows, + } + } + + public destroy(): void { + this.clearSelectionHighlight() + try { + const renderLib = resolveRenderLib() + + if (this.ptySession) { + renderLib.terminalSessionDestroy(this.ptySession) + this.ptySession = null + } + } catch (error) { + console.error("Failed to destroy terminal:", error) + } + + super.destroy() + } + + private updateSelectionFromGlobal(selection: GlobalSelection | null): boolean { + const metrics = this.getContentMetrics() + if (!selection || !selection.isActive) { + return this.applySelectionRect(null) + } + + const localSelection = convertGlobalToLocalSelection(selection, this.x, this.y) + if (!localSelection) { + return this.applySelectionRect(null) + } + + const adjustedAnchorX = localSelection.anchorX + const adjustedFocusX = localSelection.focusX + const adjustedAnchorY = localSelection.anchorY + const adjustedFocusY = localSelection.focusY + + const rawStartCol = Math.min(adjustedAnchorX, adjustedFocusX) + const rawEndCol = Math.max(adjustedAnchorX, adjustedFocusX) + const rawStartRow = Math.min(adjustedAnchorY, adjustedFocusY) + const rawEndRow = Math.max(adjustedAnchorY, adjustedFocusY) + + let startCol = Math.floor(rawStartCol) + let endCol = Math.ceil(rawEndCol) + let startRow = Math.floor(rawStartRow) + let endRow = Math.ceil(rawEndRow) + + if (endCol === startCol) endCol = startCol + 1 + if (endRow === startRow) endRow = startRow + 1 + + startCol = Math.max(0, Math.min(this._cols, startCol)) + endCol = Math.max(0, Math.min(this._cols, endCol)) + startRow = Math.max(0, Math.min(this._rows, startRow)) + endRow = Math.max(0, Math.min(this._rows, endRow)) + + if (endCol <= startCol || endRow <= startRow) { + return this.applySelectionRect(null) + } + + return this.applySelectionRect({ startRow, endRow, startCol, endCol }) + } + + private applySelectionRect(rect: SelectionRect | null): boolean { + if ( + rect && + this.selectionRect && + rect.startRow === this.selectionRect.startRow && + rect.endRow === this.selectionRect.endRow && + rect.startCol === this.selectionRect.startCol && + rect.endCol === this.selectionRect.endCol + ) { + return false + } + + const renderLib = resolveRenderLib() + if (this.ptySession) { + if (rect) { + if ((renderLib as any).terminalSessionSetSelection) { + ;(renderLib as any).terminalSessionSetSelection( + this.ptySession, + rect.startRow, + rect.startCol, + rect.endRow, + rect.endCol, + this._selectionFg.buffer, + this._selectionBg.buffer, + ) + } + this.selectionRect = rect + } else { + if ((renderLib as any).terminalSessionClearSelection) { + ;(renderLib as any).terminalSessionClearSelection(this.ptySession) + } + this.selectionRect = null + } + } + + return true + } + + private clearSelectionHighlight(): void { + if (!this.selectionRect || !this.ptySession) return + const renderLib = resolveRenderLib() + if ((renderLib as any).terminalSessionClearSelection) { + ;(renderLib as any).terminalSessionClearSelection(this.ptySession) + } + this.selectionRect = null + this.requestRender() + } + + private getContentMetrics(): { contentX: number; contentY: number; contentWidth: number; contentHeight: number } { + const contentX = this.x + const contentY = this.y + return { + contentX, + contentY, + contentWidth: this._cols, + contentHeight: this._rows, + } + } +} diff --git a/packages/core/src/renderables/index.ts b/packages/core/src/renderables/index.ts index f0ea958cb..4db035b62 100644 --- a/packages/core/src/renderables/index.ts +++ b/packages/core/src/renderables/index.ts @@ -8,6 +8,7 @@ export * from "./Select" export * from "./TabSelect" export * from "./ScrollBox" export * from "./ScrollBar" +export * from "./TerminalRenderer" export * from "./composition/constructs" export * from "./composition/vnode" export * from "./composition/VRenderable" diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index b8c2a4e5e..ba76cf83d 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -5,16 +5,41 @@ import { RGBA } from "./lib/RGBA" import { OptimizedBuffer } from "./buffer" import { TextBuffer } from "./text-buffer" -const module = await import(`@opentui/core-${process.platform}-${process.arch}/index.ts`) -const targetLibPath = module.default -if (!existsSync(targetLibPath)) { - throw new Error(`opentui is not supported on the current platform: ${process.platform}-${process.arch}`) +let supportsSelectionSymbols = false + +// Try optional prebuilt native package first; fall back to local dev build +let targetLibPath: string | undefined +try { + const mod = await import(`@opentui/core-${process.platform}-${process.arch}/index.ts`) + targetLibPath = (mod as any).default + if (targetLibPath && !existsSync(targetLibPath)) { + targetLibPath = undefined + } +} catch { + targetLibPath = undefined +} + +function devLocateLibPath(): string | undefined { + try { + const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : process.arch + const os = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux" + const ext = process.platform === "darwin" ? "dylib" : process.platform === "win32" ? "dll" : "so" + const url = new URL(`./zig/lib/${arch}-${os}/libopentui.${ext}`, import.meta.url) + const p = url.pathname + return existsSync(p) ? p : undefined + } catch { + return undefined + } } function getOpenTUILib(libPath?: string) { - const resolvedLibPath = libPath || targetLibPath + const fallback = devLocateLibPath() + const resolvedLibPath = libPath || targetLibPath || fallback + if (!resolvedLibPath) { + throw new Error(`OpenTUI native library not found. Install platform package or build local Zig lib.`) + } - const rawSymbols = dlopen(resolvedLibPath, { + const symbolMap: Record = { // Logging setLogCallback: { args: ["ptr"], @@ -246,6 +271,82 @@ function getOpenTUILib(libPath?: string) { returns: "void", }, + // PTY Terminal Session + terminalSessionCreate: { + args: ["u16", "u16"], + returns: "ptr", + }, + terminalSessionDestroy: { + args: ["ptr"], + returns: "void", + }, + terminalSessionWrite: { + args: ["ptr", "ptr", "usize"], + returns: "usize", + }, + terminalSessionResize: { + args: ["ptr", "u16", "u16"], + returns: "void", + }, + terminalSessionTick: { + args: ["ptr"], + returns: "i32", + }, + terminalSessionRender: { + args: ["ptr", "ptr", "u32", "u32"], + returns: "void", + }, + + // LibVTerm Terminal Renderer functions + libvtermRendererCreate: { + args: ["u16", "u16"], + returns: "ptr", + }, + libvtermRendererDestroy: { + args: ["ptr"], + returns: "void", + }, + libvtermRendererResize: { + args: ["ptr", "u16", "u16"], + returns: "void", + }, + libvtermRendererWrite: { + args: ["ptr", "ptr", "usize"], + returns: "usize", + }, + libvtermRendererKeyboardUnichar: { + args: ["ptr", "u32", "u8", "u8", "u8"], + returns: "void", + }, + libvtermRendererKeyboardKey: { + args: ["ptr", "i32", "u8", "u8", "u8"], + returns: "void", + }, + libvtermRendererMouseMove: { + args: ["ptr", "i32", "i32", "u8", "u8", "u8"], + returns: "void", + }, + libvtermRendererMouseButton: { + args: ["ptr", "i32", "u8", "u8", "u8", "u8"], + returns: "void", + }, + libvtermRendererRender: { + args: ["ptr", "ptr", "u32", "u32"], + returns: "void", + }, + libvtermRendererFlushDamage: { + args: ["ptr"], + returns: "void", + }, + libvtermRendererGetCursorPos: { + args: ["ptr", "ptr", "ptr"], + returns: "void", + }, + libvtermRendererGetCursorVisible: { + args: ["ptr"], + returns: "u8", + }, + // TextBuffer functions createTextBuffer: { args: ["u8"], @@ -368,8 +469,34 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "ptr", "usize"], returns: "void", }, - }) + } + const selectionSymbols = { + terminalSessionSetSelection: { + args: ["ptr", "i32", "i32", "i32", "i32", "ptr", "ptr"], + returns: "void", + }, + terminalSessionClearSelection: { + args: ["ptr"], + returns: "void", + }, + terminalSessionCopySelection: { + args: ["ptr", "i32", "i32", "i32", "i32", "ptr", "usize"], + returns: "usize", + }, + } as const + + // Try to load with selection symbols first + let rawSymbols: any + try { + rawSymbols = dlopen(resolvedLibPath, { ...symbolMap, ...selectionSymbols }) + supportsSelectionSymbols = true + } catch (error) { + supportsSelectionSymbols = false + rawSymbols = dlopen(resolvedLibPath, symbolMap) + } + + // Apply debug/trace wrappers if enabled if (process.env.DEBUG_FFI === "true" || process.env.TRACE_FFI === "true") { return { symbols: convertToDebugSymbols(rawSymbols.symbols), @@ -749,16 +876,73 @@ export interface RenderLib { getTerminalCapabilities: (renderer: Pointer) => any processCapabilityResponse: (renderer: Pointer, response: string) => void + + // PTY TerminalSession + terminalSessionCreate: (cols: number, rows: number) => Pointer | null + terminalSessionDestroy: (session: Pointer) => void + terminalSessionWrite: (session: Pointer, data: Uint8Array) => number + terminalSessionResize: (session: Pointer, cols: number, rows: number) => void + terminalSessionTick: (session: Pointer) => number + terminalSessionRender: (session: Pointer, buffer: Pointer, x: number, y: number) => void + terminalSessionSetSelection?: ( + session: Pointer, + startRow: number, + startCol: number, + endRow: number, + endCol: number, + selectionFg: Float32Array | null, + selectionBg: Float32Array | null, + ) => void + terminalSessionClearSelection?: (session: Pointer) => void + terminalSessionCopySelection?: ( + session: Pointer, + startRow: number, + startCol: number, + endRow: number, + endCol: number, + outBuffer: Uint8Array, + outLen: number, + ) => number + + // LibVTerm Terminal Renderer + libvtermRendererCreate: (rows: number, cols: number) => Pointer | null + libvtermRendererDestroy: (renderer: Pointer) => void + libvtermRendererResize: (renderer: Pointer, cols: number, rows: number) => void + libvtermRendererWrite: (renderer: Pointer, dataPtr: Pointer, len: number) => number + libvtermRendererKeyboardUnichar: (renderer: Pointer, char: number, shift: number, alt: number, ctrl: number) => void + libvtermRendererKeyboardKey: (renderer: Pointer, key: number, shift: number, alt: number, ctrl: number) => void + libvtermRendererMouseMove: ( + renderer: Pointer, + row: number, + col: number, + shift: number, + alt: number, + ctrl: number, + ) => void + libvtermRendererMouseButton: ( + renderer: Pointer, + button: number, + pressed: number, + shift: number, + alt: number, + ctrl: number, + ) => void + libvtermRendererRender: (renderer: Pointer, buffer: Pointer, x: number, y: number) => void + libvtermRendererFlushDamage: (renderer: Pointer) => void + libvtermRendererGetCursorPos: (renderer: Pointer, rowPtr: Pointer, colPtr: Pointer) => void + libvtermRendererGetCursorVisible: (renderer: Pointer) => number } class FFIRenderLib implements RenderLib { - private opentui: ReturnType + private opentui: any + private selectionSupported: boolean public readonly encoder: TextEncoder = new TextEncoder() public readonly decoder: TextDecoder = new TextDecoder() private logCallbackWrapper: any // Store the FFI callback wrapper constructor(libPath?: string) { this.opentui = getOpenTUILib(libPath) + this.selectionSupported = supportsSelectionSymbols this.setupLogging() } @@ -820,7 +1004,7 @@ class FFIRenderLib implements RenderLib { } public createRenderer(width: number, height: number, options: { testing: boolean } = { testing: false }) { - return this.opentui.symbols.createRenderer(width, height, options.testing) + return this.opentui.symbols.createRenderer(width, height, options.testing) as Pointer | null } public destroyRenderer(renderer: Pointer): void { @@ -1512,6 +1696,132 @@ class FFIRenderLib implements RenderLib { const responseBytes = this.encoder.encode(response) this.opentui.symbols.processCapabilityResponse(renderer, responseBytes, responseBytes.length) } + + // PTY TerminalSession wrappers + public terminalSessionCreate(cols: number, rows: number): Pointer | null { + return this.opentui.symbols.terminalSessionCreate(cols, rows) + } + public terminalSessionDestroy(session: Pointer): void { + this.opentui.symbols.terminalSessionDestroy(session) + } + public terminalSessionWrite(session: Pointer, data: Uint8Array): number { + return Number(this.opentui.symbols.terminalSessionWrite(session, ptr(data), data.length)) + } + public terminalSessionResize(session: Pointer, cols: number, rows: number): void { + this.opentui.symbols.terminalSessionResize(session, cols, rows) + } + public terminalSessionTick(session: Pointer): number { + return this.opentui.symbols.terminalSessionTick(session) as number + } + public terminalSessionRender(session: Pointer, buffer: Pointer, x: number, y: number): void { + this.opentui.symbols.terminalSessionRender(session, buffer, x, y) + } + + public terminalSessionSetSelection( + session: Pointer, + startRow: number, + startCol: number, + endRow: number, + endCol: number, + selectionFg: Float32Array | null, + selectionBg: Float32Array | null, + ): void { + if (!this.selectionSupported || !this.opentui.symbols.terminalSessionSetSelection) { + return + } + const fgPtr = selectionFg ? ptr(selectionFg) : null + const bgPtr = selectionBg ? ptr(selectionBg) : null + this.opentui.symbols.terminalSessionSetSelection(session, startRow, startCol, endRow, endCol, fgPtr, bgPtr) + } + + public terminalSessionClearSelection(session: Pointer): void { + if (!this.selectionSupported || !this.opentui.symbols.terminalSessionClearSelection) { + return + } + this.opentui.symbols.terminalSessionClearSelection(session) + } + + public terminalSessionCopySelection( + session: Pointer, + startRow: number, + startCol: number, + endRow: number, + endCol: number, + outBuffer: Uint8Array, + outLen: number, + ): number { + if (!this.selectionSupported || !this.opentui.symbols.terminalSessionCopySelection) { + return 0 + } + const outPtr = ptr(outBuffer) + return this.opentui.symbols.terminalSessionCopySelection( + session, + startRow, + startCol, + endRow, + endCol, + outPtr, + outLen, + ) + } + + // LibVTerm Terminal Renderer wrappers + public libvtermRendererCreate(rows: number, cols: number): Pointer | null { + return this.opentui.symbols.libvtermRendererCreate(rows, cols) + } + public libvtermRendererDestroy(renderer: Pointer): void { + this.opentui.symbols.libvtermRendererDestroy(renderer) + } + public libvtermRendererResize(renderer: Pointer, cols: number, rows: number): void { + this.opentui.symbols.libvtermRendererResize(renderer, cols, rows) + } + public libvtermRendererWrite(renderer: Pointer, dataPtr: Pointer, len: number): number { + return Number(this.opentui.symbols.libvtermRendererWrite(renderer, dataPtr, len)) + } + public libvtermRendererKeyboardUnichar( + renderer: Pointer, + char: number, + shift: number, + alt: number, + ctrl: number, + ): void { + this.opentui.symbols.libvtermRendererKeyboardUnichar(renderer, char, shift, alt, ctrl) + } + public libvtermRendererKeyboardKey(renderer: Pointer, key: number, shift: number, alt: number, ctrl: number): void { + this.opentui.symbols.libvtermRendererKeyboardKey(renderer, key, shift, alt, ctrl) + } + public libvtermRendererMouseMove( + renderer: Pointer, + row: number, + col: number, + shift: number, + alt: number, + ctrl: number, + ): void { + this.opentui.symbols.libvtermRendererMouseMove(renderer, row, col, shift, alt, ctrl) + } + public libvtermRendererMouseButton( + renderer: Pointer, + button: number, + pressed: number, + shift: number, + alt: number, + ctrl: number, + ): void { + this.opentui.symbols.libvtermRendererMouseButton(renderer, button, pressed, shift, alt, ctrl) + } + public libvtermRendererRender(renderer: Pointer, buffer: Pointer, x: number, y: number): void { + this.opentui.symbols.libvtermRendererRender(renderer, buffer, x, y) + } + public libvtermRendererFlushDamage(renderer: Pointer): void { + this.opentui.symbols.libvtermRendererFlushDamage(renderer) + } + public libvtermRendererGetCursorPos(renderer: Pointer, rowPtr: Pointer, colPtr: Pointer): void { + this.opentui.symbols.libvtermRendererGetCursorPos(renderer, rowPtr, colPtr) + } + public libvtermRendererGetCursorVisible(renderer: Pointer): number { + return this.opentui.symbols.libvtermRendererGetCursorVisible(renderer) as number + } } let opentuiLibPath: string | undefined @@ -1527,6 +1837,7 @@ export function setRenderLibPath(libPath: string) { export function resolveRenderLib(): RenderLib { if (!opentuiLib) { try { + console.log("Resolving render lib path:", opentuiLibPath) opentuiLib = new FFIRenderLib(opentuiLibPath) } catch (error) { throw new Error( diff --git a/packages/core/src/zig/buffer.zig b/packages/core/src/zig/buffer.zig index 41dad7f82..75acad1f6 100644 --- a/packages/core/src/zig/buffer.zig +++ b/packages/core/src/zig/buffer.zig @@ -1205,6 +1205,7 @@ pub const OptimizedBuffer = struct { terminalWidthCells: u32, terminalHeightCells: u32, ) void { + _ = terminalHeightCells; // May be used for future optimizations const cellResultSize = 48; const numCells = dataLen / cellResultSize; const bufferWidthCells = terminalWidthCells; @@ -1216,7 +1217,7 @@ pub const OptimizedBuffer = struct { const cellX = posX + @as(u32, @intCast(i % bufferWidthCells)); const cellY = posY + @as(u32, @intCast(i / bufferWidthCells)); - if (cellX >= terminalWidthCells or cellY >= terminalHeightCells) continue; + // Skip cells outside the render buffer bounds if (cellX >= self.width or cellY >= self.height) continue; if (!self.isPointInScissor(@intCast(cellX), @intCast(cellY))) continue; diff --git a/packages/core/src/zig/build.zig b/packages/core/src/zig/build.zig index 0d864b231..d14c0c242 100644 --- a/packages/core/src/zig/build.zig +++ b/packages/core/src/zig/build.zig @@ -43,6 +43,107 @@ const SUPPORTED_TARGETS = [_]SupportedTarget{ const LIB_NAME = "opentui"; const ROOT_SOURCE_FILE = "lib.zig"; +const LibvtermConfig = struct { + available: bool, + include_paths: []const []const u8, + library_paths: []const []const u8, + compile_flags: []const []const u8, +}; + +fn detectLibvterm(b: *std.Build, target: std.Build.ResolvedTarget) LibvtermConfig { + _ = b; // Suppress unused parameter warning + + switch (target.result.os.tag) { + .macos => { + // Only enable libvterm for native architecture to avoid cross-compilation issues + const native_arch = builtin.cpu.arch; + if (target.result.cpu.arch != native_arch) { + // Skip libvterm for cross-compilation targets + return LibvtermConfig{ + .available = false, + .include_paths = &[_][]const u8{}, + .library_paths = &[_][]const u8{}, + .compile_flags = &[_][]const u8{}, + }; + } + + // Try Homebrew paths based on native architecture + switch (native_arch) { + .aarch64 => { + // Apple Silicon Homebrew path + if (std.fs.accessAbsolute("/opt/homebrew/opt/libvterm/include/vterm.h", .{})) { + return LibvtermConfig{ + .available = true, + .include_paths = &[_][]const u8{"/opt/homebrew/opt/libvterm/include"}, + .library_paths = &[_][]const u8{"/opt/homebrew/opt/libvterm/lib"}, + .compile_flags = &[_][]const u8{ "-I/opt/homebrew/opt/libvterm/include", "-std=c99" }, + }; + } else |_| {} + }, + .x86_64 => { + // Intel Homebrew path + if (std.fs.accessAbsolute("/usr/local/opt/libvterm/include/vterm.h", .{})) { + return LibvtermConfig{ + .available = true, + .include_paths = &[_][]const u8{"/usr/local/opt/libvterm/include"}, + .library_paths = &[_][]const u8{"/usr/local/opt/libvterm/lib"}, + .compile_flags = &[_][]const u8{ "-I/usr/local/opt/libvterm/include", "-std=c99" }, + }; + } else |_| {} + }, + else => { + // Unsupported architecture for macOS + }, + } + + // Try system paths as fallback + if (std.fs.accessAbsolute("/usr/include/vterm.h", .{})) { + return LibvtermConfig{ + .available = true, + .include_paths = &[_][]const u8{"/usr/include"}, + .library_paths = &[_][]const u8{"/usr/lib"}, + .compile_flags = &[_][]const u8{ "-I/usr/include", "-std=c99" }, + }; + } else |_| {} + }, + .linux => { + // Check common Linux package manager paths + if (std.fs.accessAbsolute("/usr/include/vterm.h", .{})) { + return LibvtermConfig{ + .available = true, + .include_paths = &[_][]const u8{"/usr/include"}, + .library_paths = &[_][]const u8{"/usr/lib"}, + .compile_flags = &[_][]const u8{ "-I/usr/include", "-std=c99" }, + }; + } else |_| {} + + if (std.fs.accessAbsolute("/usr/local/include/vterm.h", .{})) { + return LibvtermConfig{ + .available = true, + .include_paths = &[_][]const u8{"/usr/local/include"}, + .library_paths = &[_][]const u8{"/usr/local/lib"}, + .compile_flags = &[_][]const u8{ "-I/usr/local/include", "-std=c99" }, + }; + } else |_| {} + }, + .windows => { + // Windows support would require vcpkg or manual installation + // For now, disable libvterm on Windows + }, + else => { + // Other platforms not supported yet + }, + } + + // libvterm not available + return LibvtermConfig{ + .available = false, + .include_paths = &[_][]const u8{}, + .library_paths = &[_][]const u8{}, + .compile_flags = &[_][]const u8{}, + }; +} + fn checkZigVersion() void { const current_version = builtin.zig_version; var is_supported = false; @@ -140,13 +241,51 @@ fn buildTargetFromQuery( const target = b.resolveTargetQuery(target_query); var target_output: *std.Build.Step.Compile = undefined; + const link_libc_needed = switch (target.result.os.tag) { + .macos, .linux => true, + else => false, + }; + const module = b.addModule(LIB_NAME, .{ .root_source_file = b.path(ROOT_SOURCE_FILE), .target = target, .optimize = optimize, - .link_libc = false, + .link_libc = link_libc_needed, }); + // Add libvterm support with cross-platform detection + const libvterm_config = detectLibvterm(b, target); + + // Create build options to inform Zig code about libvterm availability + const build_options = b.addOptions(); + build_options.addOption(bool, "has_libvterm", libvterm_config.available); + module.addOptions("build_options", build_options); + + if (libvterm_config.available) { + std.debug.print("Building with libvterm support for {s}\n", .{description}); + + // Add include paths + for (libvterm_config.include_paths) |include_path| { + module.addIncludePath(.{ .cwd_relative = include_path }); + } + + // Add library paths + for (libvterm_config.library_paths) |library_path| { + module.addLibraryPath(.{ .cwd_relative = library_path }); + } + + // Link libvterm + module.linkSystemLibrary("vterm", .{}); + + // Add our wrapper C file with appropriate flags + module.addCSourceFile(.{ + .file = b.path("vterm_wrapper.c"), + .flags = libvterm_config.compile_flags, + }); + } else { + std.debug.print("Building without libvterm support for {s} (not available)\n", .{description}); + } + applyZgDependencies(b, module, optimize, target); target_output = b.addLibrary(.{ diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index a750bdae8..722947689 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -9,6 +9,8 @@ const text_buffer = @import("text-buffer.zig"); const terminal = @import("terminal.zig"); const gwidth = @import("gwidth.zig"); const logger = @import("logger.zig"); +const ts = @import("terminal_session.zig"); +const libvterm = @import("libvterm.zig"); pub const OptimizedBuffer = buffer.OptimizedBuffer; pub const CliRenderer = renderer.CliRenderer; @@ -326,6 +328,85 @@ export fn checkHit(rendererPtr: *renderer.CliRenderer, x: u32, y: u32) u32 { return rendererPtr.checkHit(x, y); } +// ======================== +// Native PTY TerminalSession API +// ======================== + +export fn terminalSessionCreate(cols: u16, rows: u16) ?*ts.TerminalSession { + return ts.TerminalSession.create(globalArena, cols, rows) catch null; +} + +export fn terminalSessionDestroy(session: *ts.TerminalSession) void { + session.destroy(); +} + +export fn terminalSessionWrite(session: *ts.TerminalSession, data: [*]const u8, len: usize) usize { + return session.write(data[0..len]); +} + +export fn terminalSessionResize(session: *ts.TerminalSession, cols: u16, rows: u16) void { + session.resize(cols, rows); +} + +export fn terminalSessionTick(session: *ts.TerminalSession) i32 { + return session.tick(); +} + +export fn terminalSessionRender(session: *ts.TerminalSession, bufferPtr: *buffer.OptimizedBuffer, x: u32, y: u32) void { + session.render(bufferPtr, x, y); +} + +fn rgbaFromPtr(ptr: ?*const [4]f32, default: buffer.RGBA) buffer.RGBA { + if (ptr) |p| { + return p.*; + } + return default; +} + +export fn terminalSessionSetSelection( + session: *ts.TerminalSession, + start_row: c_int, + start_col: c_int, + end_row: c_int, + end_col: c_int, + fg_ptr: ?*const [4]f32, + bg_ptr: ?*const [4]f32, +) void { + const rect = libvterm.SelectionRect{ + .start_row = start_row, + .end_row = end_row, + .start_col = start_col, + .end_col = end_col, + }; + + const fg = rgbaFromPtr(fg_ptr, buffer.RGBA{ 0.0, 0.0, 0.0, 1.0 }); + const bg = rgbaFromPtr(bg_ptr, buffer.RGBA{ 0.7, 0.7, 0.9, 1.0 }); + + session.setSelection(rect, fg, bg); +} + +export fn terminalSessionClearSelection(session: *ts.TerminalSession) void { + session.clearSelection(); +} + +export fn terminalSessionCopySelection( + session: *ts.TerminalSession, + start_row: c_int, + start_col: c_int, + end_row: c_int, + end_col: c_int, + out_ptr: [*]u8, + out_len: usize, +) usize { + const rect = libvterm.SelectionRect{ + .start_row = start_row, + .end_row = end_row, + .start_col = start_col, + .end_col = end_col, + }; + return session.copySelection(rect, out_ptr[0..out_len]); +} + export fn dumpHitGrid(rendererPtr: *renderer.CliRenderer) void { rendererPtr.dumpHitGrid(); } @@ -522,3 +603,55 @@ export fn textBufferSetWrapMode(tb: *text_buffer.TextBuffer, mode: u8) void { }; tb.setWrapMode(wrapMode); } + +// ======================== +// LibVTerm Terminal Renderer API +// ======================== + +export fn libvtermRendererCreate(rows: u16, cols: u16) ?*libvterm.LibVTermRenderer { + return libvterm.libvterm_create(cols, rows); +} + +export fn libvtermRendererDestroy(vterm_renderer: *libvterm.LibVTermRenderer) void { + libvterm.libvterm_destroy(vterm_renderer); +} + +export fn libvtermRendererResize(vterm_renderer: *libvterm.LibVTermRenderer, cols: u16, rows: u16) void { + libvterm.libvterm_resize(vterm_renderer, cols, rows); +} + +export fn libvtermRendererWrite(vterm_renderer: *libvterm.LibVTermRenderer, data: [*]const u8, len: usize) usize { + return libvterm.libvterm_write(vterm_renderer, data, len); +} + +export fn libvtermRendererKeyboardUnichar(vterm_renderer: *libvterm.LibVTermRenderer, char: u32, shift: u8, alt: u8, ctrl: u8) void { + libvterm.libvterm_keyboard_unichar(vterm_renderer, char, shift, alt, ctrl); +} + +export fn libvtermRendererKeyboardKey(vterm_renderer: *libvterm.LibVTermRenderer, key: i32, shift: u8, alt: u8, ctrl: u8) void { + libvterm.libvterm_keyboard_key(vterm_renderer, key, shift, alt, ctrl); +} + +export fn libvtermRendererMouseMove(vterm_renderer: *libvterm.LibVTermRenderer, row: i32, col: i32, shift: u8, alt: u8, ctrl: u8) void { + libvterm.libvterm_mouse_move(vterm_renderer, row, col, shift, alt, ctrl); +} + +export fn libvtermRendererMouseButton(vterm_renderer: *libvterm.LibVTermRenderer, button: i32, pressed: u8, shift: u8, alt: u8, ctrl: u8) void { + libvterm.libvterm_mouse_button(vterm_renderer, button, pressed, shift, alt, ctrl); +} + +export fn libvtermRendererRender(vterm_renderer: *libvterm.LibVTermRenderer, target: *buffer.OptimizedBuffer, x: u32, y: u32) void { + libvterm.libvterm_render(vterm_renderer, target, x, y); +} + +export fn libvtermRendererFlushDamage(vterm_renderer: *libvterm.LibVTermRenderer) void { + libvterm.libvterm_flush_damage(vterm_renderer); +} + +export fn libvtermRendererGetCursorPos(vterm_renderer: *libvterm.LibVTermRenderer, row: *i32, col: *i32) void { + libvterm.libvterm_get_cursor_pos(vterm_renderer, row, col); +} + +export fn libvtermRendererGetCursorVisible(vterm_renderer: *libvterm.LibVTermRenderer) u8 { + return libvterm.libvterm_get_cursor_visible(vterm_renderer); +} diff --git a/packages/core/src/zig/libvterm.zig b/packages/core/src/zig/libvterm.zig new file mode 100644 index 000000000..b7c1df2fd --- /dev/null +++ b/packages/core/src/zig/libvterm.zig @@ -0,0 +1,768 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const buffer = @import("buffer.zig"); +const build_options = @import("build_options"); + +// =============================== +// libvterm integration using C wrapper to avoid Zig @cImport issues +// =============================== + +// Use build-time detection of libvterm availability +pub const HAS_LIBVTERM = build_options.has_libvterm; + +// Define core types that work across platforms +pub const VTermPos = struct { + row: i32, + col: i32, +}; + +pub const VTermRect = struct { + start_row: i32, + end_row: i32, + start_col: i32, + end_col: i32, +}; + +pub const SelectionRect = struct { + start_row: i32, + end_row: i32, + start_col: i32, + end_col: i32, + + pub fn normalize(self: SelectionRect, max_rows: u16, max_cols: u16) ?SelectionRect { + var start_row = @max(0, @min(self.start_row, self.end_row)); + var end_row = @max(self.start_row, self.end_row); + var start_col = @max(0, @min(self.start_col, self.end_col)); + var end_col = @max(self.start_col, self.end_col); + + const max_r: i32 = @intCast(max_rows); + const max_c: i32 = @intCast(max_cols); + + start_row = @max(0, @min(start_row, max_r)); + end_row = @max(0, @min(end_row, max_r)); + start_col = @max(0, @min(start_col, max_c)); + end_col = @max(0, @min(end_col, max_c)); + + if (end_row <= start_row or end_col <= start_col) return null; + + return SelectionRect{ + .start_row = start_row, + .end_row = end_row, + .start_col = start_col, + .end_col = end_col, + }; + } +}; + +pub const SelectionColors = struct { + fg: buffer.RGBA, + bg: buffer.RGBA, +}; + +pub const VTermColor = struct { + r: u8, + g: u8, + b: u8, + is_default: bool, + + pub fn to_rgba(self: VTermColor) buffer.RGBA { + // Use proper defaults when is_default is true + if (self.is_default) { + // Default foreground is white, but we'll determine that in context + // For now, return white as a safe default + return .{ 1.0, 1.0, 1.0, 1.0 }; + } + return .{ + @as(f32, @floatFromInt(self.r)) / 255.0, + @as(f32, @floatFromInt(self.g)) / 255.0, + @as(f32, @floatFromInt(self.b)) / 255.0, + 1.0, + }; + } +}; + +pub const VTermScreenCellAttrs = struct { + bold: bool, + underline: u2, + italic: bool, + blink: bool, + reverse: bool, + conceal: bool, + strike: bool, +}; + +pub const VTermScreenCell = struct { + chars: [6]u32, + width: i8, + attrs: VTermScreenCellAttrs, + fg: VTermColor, + bg: VTermColor, +}; + +pub const VTermModifier = packed struct(u8) { + shift: bool = false, + alt: bool = false, + ctrl: bool = false, + _padding: u5 = 0, + + pub fn to_c_uint(self: VTermModifier) c_uint { + var result: c_uint = 0; + if (self.shift) result |= 1; // VTERM_MOD_SHIFT + if (self.alt) result |= 2; // VTERM_MOD_ALT + if (self.ctrl) result |= 4; // VTERM_MOD_CTRL + return result; + } +}; + +// Key constants +pub const VTermKey = struct { + pub const NONE = 0; + pub const ENTER = 1; + pub const TAB = 2; + pub const BACKSPACE = 3; + pub const ESCAPE = 4; + pub const UP = 5; + pub const DOWN = 6; + pub const LEFT = 7; + pub const RIGHT = 8; + pub const INS = 9; + pub const DEL = 10; + pub const HOME = 11; + pub const END = 12; + pub const PAGEUP = 13; + pub const PAGEDOWN = 14; +}; + +// Platform-specific implementation +const LibVTermImpl = if (HAS_LIBVTERM) struct { + // Import only our wrapper functions to avoid problematic types + extern fn vterm_wrapper_new(rows: c_int, cols: c_int) ?*anyopaque; + extern fn vterm_wrapper_free(vt: *anyopaque) void; + extern fn vterm_wrapper_set_size(vt: *anyopaque, rows: c_int, cols: c_int) void; + extern fn vterm_wrapper_set_utf8(vt: *anyopaque, is_utf8: c_int) void; + extern fn vterm_wrapper_input_write(vt: *anyopaque, bytes: [*]const u8, len: usize) usize; + + extern fn vterm_wrapper_obtain_screen(vt: *anyopaque) ?*anyopaque; + extern fn vterm_wrapper_screen_enable_altscreen(screen: *anyopaque, altscreen: c_int) void; + extern fn vterm_wrapper_screen_flush_damage(screen: *anyopaque) void; + extern fn vterm_wrapper_screen_reset(screen: *anyopaque, hard: c_int) void; + extern fn vterm_wrapper_screen_get_cell( + screen: *anyopaque, + row: c_int, + col: c_int, + chars: [*]u32, + width: *i8, + bold: *c_int, + underline: *c_int, + italic: *c_int, + blink: *c_int, + reverse: *c_int, + conceal: *c_int, + strike: *c_int, + fg_r: *c_int, + fg_g: *c_int, + fg_b: *c_int, + fg_default: *c_int, + bg_r: *c_int, + bg_g: *c_int, + bg_b: *c_int, + bg_default: *c_int + ) c_int; + + extern fn vterm_wrapper_obtain_state(vt: *anyopaque) ?*anyopaque; + extern fn vterm_wrapper_state_get_cursorpos(state: *anyopaque, row: *c_int, col: *c_int) void; + extern fn vterm_wrapper_state_get_default_colors( + state: *anyopaque, + fg_r: *c_int, + fg_g: *c_int, + fg_b: *c_int, + bg_r: *c_int, + bg_g: *c_int, + bg_b: *c_int, + ) void; + extern fn vterm_wrapper_keyboard_unichar(vt: *anyopaque, c: u32, mod: c_uint) void; + extern fn vterm_wrapper_keyboard_key(vt: *anyopaque, key: c_uint, mod: c_uint) void; + extern fn vterm_wrapper_mouse_move(vt: *anyopaque, row: c_int, col: c_int, mod: c_uint) void; + extern fn vterm_wrapper_mouse_button(vt: *anyopaque, button: c_int, pressed: c_int, mod: c_uint) void; + + extern fn vterm_wrapper_enable_callbacks(screen: *anyopaque) void; + extern fn vterm_wrapper_disable_callbacks(screen: *anyopaque) void; + extern fn vterm_wrapper_poll_callbacks( + screen: *anyopaque, + cursor_row: *c_int, + cursor_col: *c_int, + cursor_visible: *c_int, + damage_pending: *c_int, + damage_rect: *VTermRect, + ) void; + + pub const LibVTermRenderer = struct { + allocator: std.mem.Allocator, + vterm: *anyopaque, + screen: *anyopaque, + state: *anyopaque, + cols: u16, + rows: u16, + cursor_pos: VTermPos, + cursor_visible: bool, + default_fg: buffer.RGBA, + default_bg: buffer.RGBA, + selection_rect: ?SelectionRect, + selection_colors: SelectionColors, + pending_damage: ?VTermRect, + + const Self = @This(); + + pub fn init(allocator: std.mem.Allocator, cols: u16, rows: u16) !*Self { + const vterm = vterm_wrapper_new(@intCast(rows), @intCast(cols)) orelse return error.VTermCreateFailed; + + const screen = vterm_wrapper_obtain_screen(vterm); + const state = vterm_wrapper_obtain_state(vterm); + + if (screen == null or state == null) { + vterm_wrapper_free(vterm); + return error.VTermInitFailed; + } + + const self = try allocator.create(Self); + self.* = .{ + .allocator = allocator, + .vterm = vterm, + .screen = screen.?, + .state = state.?, + .cols = cols, + .rows = rows, + .cursor_pos = .{ .row = 0, .col = 0 }, + .cursor_visible = true, + .default_fg = .{ 1.0, 1.0, 1.0, 1.0 }, + .default_bg = .{ 0.0, 0.0, 0.0, 1.0 }, + .selection_rect = null, + .selection_colors = SelectionColors{ + .fg = .{ 0.0, 0.0, 0.0, 1.0 }, + .bg = .{ 0.7, 0.7, 0.9, 1.0 }, + }, + .pending_damage = null, + }; + + // Set up callbacks for cursor tracking and damage notifications + self.setupCallbacks(); + + // Enable UTF-8 support + vterm_wrapper_set_utf8(vterm, 1); + + // Reset the screen to initialize it properly + vterm_wrapper_screen_reset(screen.?, 1); // Use hard reset + + // Disable alternate screen - we want normal screen for PTY + vterm_wrapper_screen_enable_altscreen(screen.?, 0); + + self.refreshState(); + + return self; + } + + pub fn deinit(self: *Self) void { + vterm_wrapper_disable_callbacks(self.screen); + vterm_wrapper_free(self.vterm); + self.allocator.destroy(self); + } + + fn setupCallbacks(self: *Self) void { + vterm_wrapper_enable_callbacks(self.screen); + } + + pub fn resize(self: *Self, cols: u16, rows: u16) void { + self.cols = cols; + self.rows = rows; + vterm_wrapper_set_size(self.vterm, @intCast(rows), @intCast(cols)); + self.refreshState(); + } + + pub fn write(self: *Self, data: []const u8) usize { + // Add basic validation + if (data.len == 0) return 0; + + const written = vterm_wrapper_input_write(self.vterm, data.ptr, data.len); + self.refreshState(); + return written; + } + + pub fn keyboardUnichar(self: *Self, char: u32, modifier: VTermModifier) void { + vterm_wrapper_keyboard_unichar(self.vterm, char, modifier.to_c_uint()); + self.refreshState(); + } + + pub fn keyboardKey(self: *Self, key: c_int, modifier: VTermModifier) void { + vterm_wrapper_keyboard_key(self.vterm, @intCast(key), modifier.to_c_uint()); + self.refreshState(); + } + + pub fn mouseMove(self: *Self, row: i32, col: i32, modifier: VTermModifier) void { + vterm_wrapper_mouse_move(self.vterm, row, col, modifier.to_c_uint()); + self.refreshState(); + } + + pub fn mouseButton(self: *Self, button: i32, pressed: bool, modifier: VTermModifier) void { + vterm_wrapper_mouse_button(self.vterm, button, if (pressed) 1 else 0, modifier.to_c_uint()); + self.refreshState(); + } + + fn refreshState(self: *Self) void { + var row: c_int = 0; + var col: c_int = 0; + vterm_wrapper_state_get_cursorpos(self.state, &row, &col); + self.cursor_pos = .{ .row = row, .col = col }; + + var fg_r: c_int = 255; + var fg_g: c_int = 255; + var fg_b: c_int = 255; + var bg_r: c_int = 0; + var bg_g: c_int = 0; + var bg_b: c_int = 0; + vterm_wrapper_state_get_default_colors(self.state, &fg_r, &fg_g, &fg_b, &bg_r, &bg_g, &bg_b); + + self.default_fg = .{ + @as(f32, @floatFromInt(std.math.clamp(fg_r, 0, 255))) / 255.0, + @as(f32, @floatFromInt(std.math.clamp(fg_g, 0, 255))) / 255.0, + @as(f32, @floatFromInt(std.math.clamp(fg_b, 0, 255))) / 255.0, + 1.0, + }; + + self.default_bg = .{ + @as(f32, @floatFromInt(std.math.clamp(bg_r, 0, 255))) / 255.0, + @as(f32, @floatFromInt(std.math.clamp(bg_g, 0, 255))) / 255.0, + @as(f32, @floatFromInt(std.math.clamp(bg_b, 0, 255))) / 255.0, + 1.0, + }; + + var cb_row: c_int = -1; + var cb_col: c_int = -1; + var cb_visible: c_int = -1; + var cb_damage: c_int = 0; + var cb_rect: VTermRect = .{ .start_row = 0, .end_row = 0, .start_col = 0, .end_col = 0 }; + vterm_wrapper_poll_callbacks(self.screen, &cb_row, &cb_col, &cb_visible, &cb_damage, &cb_rect); + + if (cb_row >= 0 and cb_col >= 0) { + self.cursor_pos = .{ .row = cb_row, .col = cb_col }; + } + if (cb_visible >= 0) { + self.cursor_visible = cb_visible != 0; + } + self.pending_damage = if (cb_damage != 0) cb_rect else null; + } + + pub fn setSelection(self: *Self, rect: ?SelectionRect) void { + if (rect) |raw_rect| { + self.selection_rect = raw_rect.normalize(self.rows, self.cols); + } else { + self.selection_rect = null; + } + } + + pub fn setSelectionColors(self: *Self, fg: buffer.RGBA, bg: buffer.RGBA) void { + self.selection_colors = .{ .fg = fg, .bg = bg }; + } + + pub fn clearSelection(self: *Self) void { + self.selection_rect = null; + } + + pub fn hasSelection(self: *Self) bool { + return self.selection_rect != null; + } + + pub fn isCellSelected(self: *Self, row: i32, col: i32) bool { + if (self.selection_rect) |rect| { + return row >= rect.start_row and row < rect.end_row and + col >= rect.start_col and col < rect.end_col; + } + return false; + } + + pub fn getSelectionColors(self: *Self) SelectionColors { + return self.selection_colors; + } + + pub fn copySelection(self: *Self, rect: SelectionRect, out: []u8) usize { + if (rect.normalize(self.rows, self.cols)) |normalized| { + self.flushDamage(); + + var out_index: usize = 0; + var row = normalized.start_row; + const last_row = normalized.end_row - 1; + while (row < normalized.end_row and out_index < out.len) : (row += 1) { + var col = normalized.start_col; + while (col < normalized.end_col and out_index < out.len) { + const pos = VTermPos{ .row = row, .col = col }; + const maybe_cell = self.getCell(pos); + var advance: i32 = 1; + if (maybe_cell) |cell| { + advance = if (cell.width <= 0) 1 else cell.width; + var i: usize = 0; + while (i < cell.chars.len and cell.chars[i] != 0) : (i += 1) { + const maybe_codepoint = std.math.cast(u21, cell.chars[i]); + if (maybe_codepoint) |codepoint| { + var utf8_buf: [4]u8 = undefined; + const len = std.unicode.utf8Encode(codepoint, &utf8_buf) catch 0; + if (len == 0) continue; + if (out_index + len > out.len) return out_index; + @memcpy(out[out_index .. out_index + len], utf8_buf[0..len]); + out_index += len; + } + } + } else { + if (out_index < out.len) { + out[out_index] = ' '; + out_index += 1; + } + } + col += @max(advance, 1); + } + + if (row < last_row and out_index < out.len) { + out[out_index] = '\n'; + out_index += 1; + } + } + + return out_index; + } + + return 0; + } + + pub fn getCell(self: *Self, pos: VTermPos) ?VTermScreenCell { + var chars: [6]u32 = [_]u32{0} ** 6; + var width: i8 = 0; + var bold: c_int = 0; + var underline: c_int = 0; + var italic: c_int = 0; + var blink: c_int = 0; + var reverse: c_int = 0; + var conceal: c_int = 0; + var strike: c_int = 0; + var fg_r: c_int = 0; + var fg_g: c_int = 0; + var fg_b: c_int = 0; + var fg_default: c_int = 0; + var bg_r: c_int = 0; + var bg_g: c_int = 0; + var bg_b: c_int = 0; + var bg_default: c_int = 0; + + const result = vterm_wrapper_screen_get_cell( + self.screen, pos.row, pos.col, + &chars, &width, + &bold, &underline, &italic, &blink, &reverse, &conceal, &strike, + &fg_r, &fg_g, &fg_b, &fg_default, + &bg_r, &bg_g, &bg_b, &bg_default + ); + + if (result == 0) return null; + + return VTermScreenCell{ + .chars = chars, + .width = width, + .attrs = VTermScreenCellAttrs{ + .bold = bold != 0, + .underline = @intCast(@min(@as(u32, @intCast(underline)), 3)), + .italic = italic != 0, + .blink = blink != 0, + .reverse = reverse != 0, + .conceal = conceal != 0, + .strike = strike != 0, + }, + .fg = VTermColor{ + .r = @intCast(@max(0, @min(255, fg_r))), + .g = @intCast(@max(0, @min(255, fg_g))), + .b = @intCast(@max(0, @min(255, fg_b))), + .is_default = fg_default != 0, + }, + .bg = VTermColor{ + .r = @intCast(@max(0, @min(255, bg_r))), + .g = @intCast(@max(0, @min(255, bg_g))), + .b = @intCast(@max(0, @min(255, bg_b))), + .is_default = bg_default != 0, + }, + }; + } + + pub fn flushDamage(self: *Self) void { + vterm_wrapper_screen_flush_damage(self.screen); + } + + pub fn render(self: *Self, target: *buffer.OptimizedBuffer, x: u32, y: u32) void { + // Flush any pending damage + self.flushDamage(); + + // Render each cell of the terminal + var row: u16 = 0; + while (row < self.rows) : (row += 1) { + var col: u16 = 0; + while (col < self.cols) { + const pos = VTermPos{ .row = @intCast(row), .col = @intCast(col) }; + const is_selected = self.isCellSelected(@intCast(row), @intCast(col)); + if (self.getCell(pos)) |cell| { + self.renderCell(target, cell, x + col, y + row, is_selected); + + const width: u8 = if (cell.width <= 0) 1 else @intCast(cell.width); + var extra: u8 = 1; + while (extra < width and col + extra < self.cols) : (extra += 1) { + const cont_selected = self.isCellSelected(@intCast(row), @intCast(col + extra)); + self.renderWideContinuation(target, cell, x + col + extra, y + row, cont_selected); + } + + col += @as(u16, width); + continue; + } else { + // If getCell returns null, render a space with default colors + const space_cell = VTermScreenCell{ + .chars = [_]u32{' '} ++ [_]u32{0} ** 5, + .width = 1, + .attrs = .{ + .bold = false, + .underline = 0, + .italic = false, + .blink = false, + .reverse = false, + .strike = false, + .conceal = false, + }, + .fg = VTermColor{ .r = 255, .g = 255, .b = 255, .is_default = true }, + .bg = VTermColor{ .r = 0, .g = 0, .b = 0, .is_default = true }, + }; + self.renderCell(target, space_cell, x + col, y + row, is_selected); + } + col += 1; + } + } + + // Render cursor if visible + if (self.cursor_visible) { + self.renderCursor(target, x, y); + } + } + + fn renderCell(self: *Self, target: *buffer.OptimizedBuffer, cell: VTermScreenCell, x: u32, y: u32, selected: bool) void { + // Get the primary character (first in the chars array) + const char: u32 = if (cell.chars[0] != 0) cell.chars[0] else ' '; + + // Convert colors with proper defaults + var fg_color = if (cell.fg.is_default) + self.default_fg + else + cell.fg.to_rgba(); + + var bg_color = if (cell.bg.is_default) + self.default_bg + else + cell.bg.to_rgba(); + + // Apply reverse attribute + if (cell.attrs.reverse) { + const temp = fg_color; + fg_color = bg_color; + bg_color = temp; + } + + if (selected) { + fg_color = self.selection_colors.fg; + bg_color = self.selection_colors.bg; + } + + // Calculate text attributes + var attributes: u8 = 0; + if (cell.attrs.bold) attributes |= 1; // BOLD + if (cell.attrs.italic) attributes |= 4; // ITALIC + if (cell.attrs.underline > 0) attributes |= 8; // UNDERLINE + if (cell.attrs.blink) attributes |= 16; // BLINK + if (cell.attrs.strike) attributes |= 128; // STRIKETHROUGH + + // Render the cell + const buffer_cell = buffer.Cell{ + .char = char, + .fg = fg_color, + .bg = bg_color, + .attributes = attributes, + }; + target.set(x, y, buffer_cell); + } + + fn renderWideContinuation(self: *Self, target: *buffer.OptimizedBuffer, cell: VTermScreenCell, x: u32, y: u32, selected: bool) void { + var bg_color = if (cell.bg.is_default) + self.default_bg + else + cell.bg.to_rgba(); + + var fg_color = if (cell.fg.is_default) + self.default_fg + else + cell.fg.to_rgba(); + + if (cell.attrs.reverse) { + const temp = fg_color; + fg_color = bg_color; + bg_color = temp; + } + + if (selected) { + fg_color = self.selection_colors.fg; + bg_color = self.selection_colors.bg; + } + + const buffer_cell = buffer.Cell{ + .char = ' ', + .fg = fg_color, + .bg = bg_color, + .attributes = 0, + }; + target.set(x, y, buffer_cell); + } + + fn renderCursor(self: *Self, target: *buffer.OptimizedBuffer, offset_x: u32, offset_y: u32) void { + if (self.cursor_pos.row < 0 or self.cursor_pos.col < 0) return; + if (@as(u32, @intCast(self.cursor_pos.row)) >= self.rows or @as(u32, @intCast(self.cursor_pos.col)) >= self.cols) return; + + const cursor_x = offset_x + @as(u32, @intCast(self.cursor_pos.col)); + const cursor_y = offset_y + @as(u32, @intCast(self.cursor_pos.row)); + + // Get the current cell at cursor position + if (self.getCell(self.cursor_pos)) |cell| { + var fg_color = if (cell.bg.is_default) + self.default_bg + else + cell.bg.to_rgba(); + const bg_color = if (cell.fg.is_default) + self.default_fg + else + cell.fg.to_rgba(); + + // Ensure minimum contrast for cursor visibility + if (fg_color[0] + fg_color[1] + fg_color[2] < 1.5) { + fg_color = .{ 1.0, 1.0, 1.0, 1.0 }; // White + } + + const char: u32 = if (cell.chars[0] != 0) cell.chars[0] else ' '; + const cursor_cell = buffer.Cell{ + .char = char, + .fg = fg_color, + .bg = bg_color, + .attributes = 0, + }; + target.set(cursor_x, cursor_y, cursor_cell); + } + } + }; +} else struct { + // Stub implementation for non-macOS platforms + pub const LibVTermRenderer = struct { + pub fn init(_: std.mem.Allocator, _: u16, _: u16) !*@This() { + return error.LibVTermNotSupported; + } + + pub fn deinit(_: *@This()) void {} + pub fn resize(_: *@This(), _: u16, _: u16) void {} + pub fn write(_: *@This(), _: []const u8) usize { return 0; } + pub fn keyboardUnichar(_: *@This(), _: u32, _: VTermModifier) void {} + pub fn keyboardKey(_: *@This(), _: c_int, _: VTermModifier) void {} + pub fn mouseMove(_: *@This(), _: i32, _: i32, _: VTermModifier) void {} + pub fn mouseButton(_: *@This(), _: i32, _: bool, _: VTermModifier) void {} + pub fn render(_: *@This(), _: *buffer.OptimizedBuffer, _: u32, _: u32) void {} + pub fn flushDamage(_: *@This()) void {} + }; +}; + +// Export the appropriate implementation +pub const LibVTermRenderer = LibVTermImpl.LibVTermRenderer; + +// =============================== +// C export functions for FFI +// =============================== + +pub export fn libvterm_create(cols: u16, rows: u16) ?*LibVTermRenderer { + if (!HAS_LIBVTERM) return null; + const allocator = std.heap.c_allocator; + return LibVTermRenderer.init(allocator, cols, rows) catch null; +} + +pub export fn libvterm_destroy(renderer: ?*LibVTermRenderer) void { + if (!HAS_LIBVTERM or renderer == null) return; + renderer.?.deinit(); +} + +pub export fn libvterm_resize(renderer: ?*LibVTermRenderer, cols: u16, rows: u16) void { + if (!HAS_LIBVTERM or renderer == null) return; + renderer.?.resize(cols, rows); +} + +pub export fn libvterm_write(renderer: ?*LibVTermRenderer, data: [*]const u8, len: usize) usize { + if (!HAS_LIBVTERM or renderer == null) return 0; + return renderer.?.write(data[0..len]); +} + +pub export fn libvterm_keyboard_unichar(renderer: ?*LibVTermRenderer, char: u32, shift: u8, alt: u8, ctrl: u8) void { + if (!HAS_LIBVTERM or renderer == null) return; + const modifier = VTermModifier{ + .shift = shift != 0, + .alt = alt != 0, + .ctrl = ctrl != 0, + }; + renderer.?.keyboardUnichar(char, modifier); +} + +pub export fn libvterm_keyboard_key(renderer: ?*LibVTermRenderer, key: c_int, shift: u8, alt: u8, ctrl: u8) void { + if (!HAS_LIBVTERM or renderer == null) return; + const modifier = VTermModifier{ + .shift = shift != 0, + .alt = alt != 0, + .ctrl = ctrl != 0, + }; + renderer.?.keyboardKey(key, modifier); +} + +pub export fn libvterm_mouse_move(renderer: ?*LibVTermRenderer, row: i32, col: i32, shift: u8, alt: u8, ctrl: u8) void { + if (!HAS_LIBVTERM or renderer == null) return; + const modifier = VTermModifier{ + .shift = shift != 0, + .alt = alt != 0, + .ctrl = ctrl != 0, + }; + renderer.?.mouseMove(row, col, modifier); +} + +pub export fn libvterm_mouse_button(renderer: ?*LibVTermRenderer, button: i32, pressed: u8, shift: u8, alt: u8, ctrl: u8) void { + if (!HAS_LIBVTERM or renderer == null) return; + const modifier = VTermModifier{ + .shift = shift != 0, + .alt = alt != 0, + .ctrl = ctrl != 0, + }; + renderer.?.mouseButton(button, pressed != 0, modifier); +} + +pub export fn libvterm_render(renderer: ?*LibVTermRenderer, target: *buffer.OptimizedBuffer, x: u32, y: u32) void { + if (!HAS_LIBVTERM or renderer == null) return; + renderer.?.render(target, x, y); +} + +pub export fn libvterm_flush_damage(renderer: ?*LibVTermRenderer) void { + if (!HAS_LIBVTERM or renderer == null) return; + renderer.?.flushDamage(); +} + +pub export fn libvterm_get_cursor_pos(renderer: ?*LibVTermRenderer, row: *i32, col: *i32) void { + if (!HAS_LIBVTERM or renderer == null) return; + renderer.?.refreshState(); + row.* = renderer.?.cursor_pos.row; + col.* = renderer.?.cursor_pos.col; +} + +pub export fn libvterm_get_cursor_visible(renderer: ?*LibVTermRenderer) u8 { + if (!HAS_LIBVTERM or renderer == null) return 0; + renderer.?.refreshState(); + return if (renderer.?.cursor_visible) 1 else 0; +} + +pub export fn libvterm_has_support() u8 { + return if (HAS_LIBVTERM) 1 else 0; +} diff --git a/packages/core/src/zig/libvterm_emu.zig b/packages/core/src/zig/libvterm_emu.zig new file mode 100644 index 000000000..bc542906b --- /dev/null +++ b/packages/core/src/zig/libvterm_emu.zig @@ -0,0 +1,260 @@ +const std = @import("std"); +const buffer = @import("buffer.zig"); +const libvterm = @import("libvterm.zig"); +const build_options = @import("build_options"); + +pub const RGBA = buffer.RGBA; + +fn rgba(r: f32, g: f32, b: f32, a: f32) RGBA { + return .{ r, g, b, a }; +} + +pub const LibVTermEmu = if (build_options.has_libvterm) struct { + allocator: std.mem.Allocator, + cols: u16, + rows: u16, + libvterm_renderer: *libvterm.LibVTermRenderer, + packed_buf: []u8, + + pub fn init(allocator: std.mem.Allocator, cols: u16, rows: u16) !*LibVTermEmu { + const renderer = try libvterm.LibVTermRenderer.init(allocator, cols, rows); + + const self = try allocator.create(LibVTermEmu); + self.* = .{ + .allocator = allocator, + .cols = cols, + .rows = rows, + .libvterm_renderer = renderer, + .packed_buf = &[_]u8{}, + }; + return self; + } + + pub fn deinit(self: *LibVTermEmu) void { + self.libvterm_renderer.deinit(); + if (self.packed_buf.len > 0) self.allocator.free(self.packed_buf); + self.allocator.destroy(self); + } + + pub fn clearAll(self: *LibVTermEmu) void { + const clear_seq = "\x1b[2J\x1b[H"; + _ = self.libvterm_renderer.write(clear_seq); + self.libvterm_renderer.flushDamage(); + } + + pub fn feed(self: *LibVTermEmu, bytes: []const u8) void { + _ = self.libvterm_renderer.write(bytes); + self.libvterm_renderer.flushDamage(); + } + + pub fn resize(self: *LibVTermEmu, cols: u16, rows: u16) !void { + if (cols == self.cols and rows == self.rows) return; + + self.libvterm_renderer.resize(cols, rows); + + self.cols = cols; + self.rows = rows; + + if (self.packed_buf.len > 0) { + self.allocator.free(self.packed_buf); + self.packed_buf = &[_]u8{}; + } + } + + pub fn packedView(self: *LibVTermEmu) []u8 { + const cell_size: usize = 48; + const total_cells: usize = @as(usize, self.cols) * @as(usize, self.rows); + const total_bytes: usize = total_cells * cell_size; + + if (self.packed_buf.len != total_bytes) { + if (self.packed_buf.len > 0) self.allocator.free(self.packed_buf); + self.packed_buf = self.allocator.alloc(u8, total_bytes) catch return &[_]u8{}; + } + + var off: usize = 0; + var row: u16 = 0; + while (row < self.rows) : (row += 1) { + var col: u16 = 0; + while (col < self.cols) : (col += 1) { + const pos = libvterm.VTermPos{ .row = @intCast(row), .col = @intCast(col) }; + const cell = self.libvterm_renderer.getCell(pos); + + var bg: RGBA = undefined; + var fg: RGBA = undefined; + var ch: u32 = ' '; + + if (cell) |c| { + // Get character + ch = if (c.chars[0] != 0) c.chars[0] else ' '; + + // Convert colors + fg = if (c.fg.is_default) + self.libvterm_renderer.default_fg + else + c.fg.to_rgba(); + + bg = if (c.bg.is_default) + self.libvterm_renderer.default_bg + else + c.bg.to_rgba(); + + if (c.attrs.reverse) { + const temp = fg; + fg = bg; + bg = temp; + } + + if (self.libvterm_renderer.isCellSelected(@intCast(row), @intCast(col))) { + const colors = self.libvterm_renderer.getSelectionColors(); + fg = colors.fg; + bg = colors.bg; + } + } else { + fg = self.libvterm_renderer.default_fg; + bg = self.libvterm_renderer.default_bg; + } + + // bg RGBA (4*f32) + off += writeF32(self.packed_buf[off..], bg[0]); + off += writeF32(self.packed_buf[off..], bg[1]); + off += writeF32(self.packed_buf[off..], bg[2]); + off += writeF32(self.packed_buf[off..], bg[3]); + // fg RGBA + off += writeF32(self.packed_buf[off..], fg[0]); + off += writeF32(self.packed_buf[off..], fg[1]); + off += writeF32(self.packed_buf[off..], fg[2]); + off += writeF32(self.packed_buf[off..], fg[3]); + // char u32 + off += writeU32(self.packed_buf[off..], ch); + // padding 12 bytes + off += 12; + } + } + + return self.packed_buf; + } + + pub fn keyboardUnichar(self: *LibVTermEmu, char: u32, shift: bool, alt: bool, ctrl: bool) void { + const modifier = libvterm.VTermModifier{ + .shift = shift, + .alt = alt, + .ctrl = ctrl, + }; + self.libvterm_renderer.keyboardUnichar(char, modifier); + self.libvterm_renderer.flushDamage(); + } + + pub fn keyboardKey(self: *LibVTermEmu, key: c_int, shift: bool, alt: bool, ctrl: bool) void { + const modifier = libvterm.VTermModifier{ + .shift = shift, + .alt = alt, + .ctrl = ctrl, + }; + self.libvterm_renderer.keyboardKey(key, modifier); + self.libvterm_renderer.flushDamage(); + } + + pub fn setSelection(self: *LibVTermEmu, rect: ?libvterm.SelectionRect, fg: RGBA, bg: RGBA) void { + self.libvterm_renderer.setSelection(rect); + self.libvterm_renderer.setSelectionColors(fg, bg); + } + + pub fn clearSelection(self: *LibVTermEmu) void { + self.libvterm_renderer.clearSelection(); + } + + pub fn hasSelection(self: *LibVTermEmu) bool { + return self.libvterm_renderer.hasSelection(); + } + + pub fn copySelection(self: *LibVTermEmu, rect: libvterm.SelectionRect, out: []u8) usize { + return self.libvterm_renderer.copySelection(rect, out); + } +} else struct { + // Stub implementation for platforms without libvterm + allocator: std.mem.Allocator, + cols: u16, + rows: u16, + packed_buf: []u8, + + pub fn init(allocator: std.mem.Allocator, cols: u16, rows: u16) !*@This() { + _ = allocator; + _ = cols; + _ = rows; + return error.LibVTermNotSupported; + } + + pub fn deinit(self: *@This()) void { + _ = self; + } + + pub fn clearAll(self: *@This()) void { + _ = self; + } + + pub fn feed(self: *@This(), bytes: []const u8) void { + _ = self; + _ = bytes; + } + + pub fn resize(self: *@This(), cols: u16, rows: u16) !void { + _ = self; + _ = cols; + _ = rows; + } + + pub fn packedView(self: *@This()) []u8 { + _ = self; + return &[_]u8{}; + } + + pub fn keyboardUnichar(self: *@This(), char: u32, shift: bool, alt: bool, ctrl: bool) void { + _ = self; + _ = char; + _ = shift; + _ = alt; + _ = ctrl; + } + + pub fn keyboardKey(self: *@This(), key: c_int, shift: bool, alt: bool, ctrl: bool) void { + _ = self; + _ = key; + _ = shift; + _ = alt; + _ = ctrl; + } + + pub fn setSelection(self: *@This(), rect: ?libvterm.SelectionRect, fg: RGBA, bg: RGBA) void { + _ = self; + _ = rect; + _ = fg; + _ = bg; + } + + pub fn clearSelection(self: *@This()) void { + _ = self; + } + + pub fn hasSelection(self: *@This()) bool { + _ = self; + return false; + } + + pub fn copySelection(self: *@This(), rect: libvterm.SelectionRect, out: []u8) usize { + _ = self; + _ = rect; + _ = out; + return 0; + } +}; + +fn writeF32(buf: []u8, v: f32) usize { + const bits = @as(u32, @bitCast(v)); + std.mem.writeInt(u32, buf[0..4], bits, .little); + return 4; +} + +fn writeU32(buf: []u8, v: u32) usize { + std.mem.writeInt(u32, buf[0..4], v, .little); + return 4; +} diff --git a/packages/core/src/zig/pty.zig b/packages/core/src/zig/pty.zig new file mode 100644 index 000000000..d9afea1a9 --- /dev/null +++ b/packages/core/src/zig/pty.zig @@ -0,0 +1,188 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +/// Pty provides a cross-platform interface for creating and managing +/// pseudo-terminals. On Unix-like systems, it spawns an interactive shell +/// process connected via PTY. Windows support is stubbed. +pub const Pty = if (builtin.os.tag == .windows) struct { + master_fd: i32 = -1, + child_pid: i32 = -1, + + pub fn spawnShell(_: std.mem.Allocator, _: u16, _: u16) !*Pty { + return error.UnsupportedPlatform; + } + pub fn resize(_: *Pty, _: u16, _: u16) void {} + pub fn write(_: *Pty, _: []const u8) usize { return 0; } + pub fn readNonblocking(_: *Pty, _: []u8) !usize { return 0; } + pub fn destroy(self: *Pty, allocator: std.mem.Allocator) void { allocator.destroy(self); } +} else struct { + const c = @cImport({ + @cInclude("errno.h"); + @cInclude("fcntl.h"); + @cInclude("signal.h"); + @cInclude("stdlib.h"); + @cInclude("string.h"); + @cInclude("sys/ioctl.h"); + @cInclude("sys/wait.h"); + @cInclude("sys/types.h"); + @cInclude("termios.h"); + @cInclude("unistd.h"); + }); + + master_fd: i32, + child_pid: i32, + + /// Creates a new PTY and spawns an interactive shell process. + /// The shell is determined by $SHELL environment variable or defaults to /bin/bash. + /// Returns a Pty instance that can be used to read/write to the shell. + pub fn spawnShell(allocator: std.mem.Allocator, cols: u16, rows: u16) !*Pty { + // Open a new PTY master using posix_openpt + const flags: i32 = c.O_RDWR | c.O_NOCTTY | c.O_CLOEXEC; + const master: i32 = @intCast(c.posix_openpt(flags)); + if (master < 0) return error.OpenPtyFailed; + + if (c.grantpt(master) != 0) { + _ = c.close(master); + return error.GrantPtyFailed; + } + if (c.unlockpt(master) != 0) { + _ = c.close(master); + return error.UnlockPtyFailed; + } + + const name = c.ptsname(master); + if (name == null) { + _ = c.close(master); + return error.PtsNameFailed; + } + + // Set non-blocking on master + const cur_flags = c.fcntl(master, c.F_GETFL, @as(i32, 0)); + if (cur_flags >= 0) { + _ = c.fcntl(master, c.F_SETFL, cur_flags | c.O_NONBLOCK); + } + + // Note: We don't configure terminal modes on the master side + // The slave side (shell) handles echo, and we display what comes back + // Setting modes on master can cause issues with some shells + + // Fork child process to exec shell + const pid: i32 = @intCast(c.fork()); + if (pid < 0) { + _ = c.close(master); + return error.ForkFailed; + } + + if (pid == 0) { + // Child + // Create new session + _ = c.setsid(); + + // Open slave + const slave: i32 = @intCast(c.open(name, c.O_RDWR | c.O_NOCTTY, @as(i32, 0))); + if (slave < 0) { + // If we fail, just exit child + c._exit(1); + } + + // Make slave controlling terminal + var one: i32 = 0; + _ = c.ioctl(slave, c.TIOCSCTTY, &one); + + // Set window size + var ws: c.struct_winsize = .{ .ws_row = rows, .ws_col = cols, .ws_xpixel = 0, .ws_ypixel = 0 }; + _ = c.ioctl(slave, c.TIOCSWINSZ, &ws); + + // Set terminal to sane defaults before the shell starts + // DISABLE ECHO to prevent double character issue + var tios: c.struct_termios = undefined; + if (c.tcgetattr(slave, &tios) == 0) { + // Enable canonical mode WITHOUT echo + tios.c_lflag = c.ICANON | c.ISIG; // No ECHO flags + // Set input flags + tios.c_iflag = c.ICRNL | c.IXON; + // Set output flags + tios.c_oflag = c.OPOST | c.ONLCR; + // Set control flags + tios.c_cflag |= c.CREAD | c.CS8; + _ = c.tcsetattr(slave, c.TCSANOW, &tios); + } + + // Duplicate stdio + _ = c.dup2(slave, 0); + _ = c.dup2(slave, 1); + _ = c.dup2(slave, 2); + + // Close inherited fds + if (slave != 0 and slave != 1 and slave != 2) _ = c.close(slave); + _ = c.close(master); + + // Basic environment setup + _ = c.setenv("TERM", "xterm-256color", 1); + + // Determine shell + var shell_buf: [256]u8 = undefined; + var shell_len: usize = 0; + if (std.process.getEnvVarOwned(std.heap.page_allocator, "SHELL")) |sh| { + defer std.heap.page_allocator.free(sh); + if (sh.len < shell_buf.len) { + std.mem.copyForwards(u8, shell_buf[0..sh.len], sh); + shell_len = sh.len; + } + } else |_| { + const fallback = "/bin/bash"; + std.mem.copyForwards(u8, shell_buf[0..fallback.len], fallback); + shell_len = fallback.len; + } + shell_buf[shell_len] = 0; // nul + + const sh_ptr: [*c]const u8 = @ptrCast(&shell_buf[0]); + // Don't print debug in child - it pollutes the PTY + var argv: [3][*c]const u8 = .{ sh_ptr, "-i", null }; + // exec: interactive shell + _ = c.execvp(sh_ptr, @ptrCast(&argv[0])); + + // If exec fails, exit child silently + c._exit(127); + } + + // Parent: set initial winsize on master to notify child + var ws2: c.struct_winsize = .{ .ws_row = rows, .ws_col = cols, .ws_xpixel = 0, .ws_ypixel = 0 }; + _ = c.ioctl(master, c.TIOCSWINSZ, &ws2); + + const self = try allocator.create(Pty); + self.* = .{ .master_fd = master, .child_pid = pid }; + return self; + } + + pub fn resize(self: *Pty, cols: u16, rows: u16) void { + var ws: c.struct_winsize = .{ .ws_row = rows, .ws_col = cols, .ws_xpixel = 0, .ws_ypixel = 0 }; + _ = c.ioctl(self.master_fd, c.TIOCSWINSZ, &ws); + } + + pub fn write(self: *Pty, data: []const u8) usize { + const rc = c.write(self.master_fd, data.ptr, @intCast(data.len)); + if (rc < 0) return 0; + return @intCast(rc); + } + + /// Reads available data from the PTY in non-blocking mode. + /// Returns 0 if no data is available or on transient errors. + /// The master FD is set to O_NONBLOCK during initialization. + pub fn readNonblocking(self: *Pty, out: []u8) !usize { + const n = c.read(self.master_fd, out.ptr, @intCast(out.len)); + if (n < 0) { + // Return 0 for any error (likely EAGAIN/EWOULDBLOCK) + return 0; + } + return @intCast(n); + } + + pub fn destroy(self: *Pty, allocator: std.mem.Allocator) void { + if (self.master_fd >= 0) _ = c.close(self.master_fd); + // Try to reap child non-blocking + var status: i32 = 0; + _ = c.waitpid(self.child_pid, &status, c.WNOHANG); + allocator.destroy(self); + } +}; diff --git a/packages/core/src/zig/terminal_session.zig b/packages/core/src/zig/terminal_session.zig new file mode 100644 index 000000000..d97ef65cf --- /dev/null +++ b/packages/core/src/zig/terminal_session.zig @@ -0,0 +1,89 @@ +const std = @import("std"); +const buffer = @import("buffer.zig"); +const Pty = @import("pty.zig").Pty; +const TerminalEmu = @import("libvterm_emu.zig").LibVTermEmu; +const libvterm = @import("libvterm.zig"); + +/// TerminalSession combines a PTY (pseudo-terminal) with a terminal emulator. +/// It manages the lifecycle of spawning a shell, reading its output, processing +/// ANSI escape sequences, and rendering the terminal display. +pub const TerminalSession = struct { + allocator: std.mem.Allocator, + pty: *Pty, + emu: *TerminalEmu, + read_buf: []u8, + + pub fn create(allocator: std.mem.Allocator, cols: u16, rows: u16) !*TerminalSession { + const pty = try Pty.spawnShell(allocator, cols, rows); + const emu = try TerminalEmu.init(allocator, cols, rows); + const rb = try allocator.alloc(u8, 64 * 1024); + const self = try allocator.create(TerminalSession); + self.* = .{ .allocator = allocator, .pty = pty, .emu = emu, .read_buf = rb }; + return self; + } + + pub fn destroy(self: *TerminalSession) void { + self.pty.destroy(self.allocator); + self.emu.deinit(); + self.allocator.free(self.read_buf); + self.allocator.destroy(self); + } + + pub fn write(self: *TerminalSession, data: []const u8) usize { + // Write only to PTY, not to emulator + // The PTY will echo back and we'll display that + return self.pty.write(data); + } + + pub fn resize(self: *TerminalSession, cols: u16, rows: u16) void { + self.pty.resize(cols, rows); + self.emu.resize(cols, rows) catch {}; + } + + /// Reads data from the PTY and feeds it to the terminal emulator. + /// Called each frame to process any pending output from the shell. + /// Limits reading to prevent blocking the UI thread. + pub fn tick(self: *TerminalSession) i32 { + // Read a limited amount per tick to avoid starving renderer + var total: usize = 0; + var loops: usize = 0; + while (loops < 32) : (loops += 1) { + const n = self.pty.readNonblocking(self.read_buf) catch break; + if (n == 0) break; + total += n; + self.emu.feed(self.read_buf[0..n]); + if (total > 512 * 1024) break; // safety cap + } + return @intCast(total); + } + + /// Renders the terminal display to the target buffer at the specified position. + /// The terminal emulator provides a packed buffer format that includes + /// character data, foreground/background colors, and cursor position. + pub fn render(self: *TerminalSession, target: *buffer.OptimizedBuffer, x: u32, y: u32) void { + const view = self.emu.packedView(); + if (view.len == 0) return; + target.drawPackedBuffer(view.ptr, view.len, x, y, self.emu.cols, self.emu.rows); + } + + pub fn setSelection( + self: *TerminalSession, + rect: ?libvterm.SelectionRect, + fg: buffer.RGBA, + bg: buffer.RGBA, + ) void { + self.emu.setSelection(rect, fg, bg); + } + + pub fn clearSelection(self: *TerminalSession) void { + self.emu.clearSelection(); + } + + pub fn hasSelection(self: *TerminalSession) bool { + return self.emu.hasSelection(); + } + + pub fn copySelection(self: *TerminalSession, rect: libvterm.SelectionRect, out_buffer: []u8) usize { + return self.emu.copySelection(rect, out_buffer); + } +}; diff --git a/packages/core/src/zig/vterm_wrapper.c b/packages/core/src/zig/vterm_wrapper.c new file mode 100644 index 000000000..41419dbee --- /dev/null +++ b/packages/core/src/zig/vterm_wrapper.c @@ -0,0 +1,252 @@ +#include "vterm_wrapper.h" +#include +#include +#include + +typedef struct { + int cursor_row; + int cursor_col; + int cursor_visible; + int damage_pending; + VTermRect damage_rect; + VTermScreenCallbacks callbacks; +} VTermWrapperCallbackContext; + +// Core VTerm operations +VTerm* vterm_wrapper_new(int rows, int cols) { + return vterm_new(rows, cols); +} + +void vterm_wrapper_free(VTerm *vt) { + vterm_free(vt); +} + +void vterm_wrapper_set_size(VTerm *vt, int rows, int cols) { + vterm_set_size(vt, rows, cols); +} + +void vterm_wrapper_set_utf8(VTerm *vt, int is_utf8) { + vterm_set_utf8(vt, is_utf8); +} + +size_t vterm_wrapper_input_write(VTerm *vt, const char *bytes, size_t len) { + if (!vt || !bytes) return 0; + return vterm_input_write(vt, bytes, len); +} + +// Screen operations +VTermScreen* vterm_wrapper_obtain_screen(VTerm *vt) { + return vterm_obtain_screen(vt); +} + +void vterm_wrapper_screen_enable_altscreen(VTermScreen *screen, int altscreen) { + vterm_screen_enable_altscreen(screen, altscreen); +} + +void vterm_wrapper_screen_flush_damage(VTermScreen *screen) { + vterm_screen_flush_damage(screen); +} + +void vterm_wrapper_screen_reset(VTermScreen *screen, int hard) { + vterm_screen_reset(screen, hard); +} + +int vterm_wrapper_screen_get_cell(VTermScreen *screen, int row, int col, + uint32_t *chars, char *width, + int *bold, int *underline, int *italic, int *blink, + int *reverse, int *conceal, int *strike, + int *fg_r, int *fg_g, int *fg_b, int *fg_default, + int *bg_r, int *bg_g, int *bg_b, int *bg_default) { + VTermPos pos = { .row = row, .col = col }; + VTermScreenCell cell; + + int result = vterm_screen_get_cell(screen, pos, &cell); + if (result == 0) return 0; + + // Copy character data + for (int i = 0; i < VTERM_MAX_CHARS_PER_CELL && i < 6; i++) { + chars[i] = cell.chars[i]; + } + *width = cell.width; + + // Copy attributes + *bold = cell.attrs.bold; + *underline = cell.attrs.underline; + *italic = cell.attrs.italic; + *blink = cell.attrs.blink; + *reverse = cell.attrs.reverse; + *conceal = cell.attrs.conceal; + *strike = cell.attrs.strike; + + // Copy colors + VTermColor fg = cell.fg; + const int fg_is_default = VTERM_COLOR_IS_DEFAULT_FG(&fg); + if (!VTERM_COLOR_IS_RGB(&fg)) { + // Convert palette or indexed colours to RGB so Zig can render them correctly + vterm_screen_convert_color_to_rgb(screen, &fg); + } + *fg_r = fg.rgb.red; + *fg_g = fg.rgb.green; + *fg_b = fg.rgb.blue; + *fg_default = fg_is_default; + + VTermColor bg = cell.bg; + const int bg_is_default = VTERM_COLOR_IS_DEFAULT_BG(&bg); + if (!VTERM_COLOR_IS_RGB(&bg)) { + vterm_screen_convert_color_to_rgb(screen, &bg); + } + *bg_r = bg.rgb.red; + *bg_g = bg.rgb.green; + *bg_b = bg.rgb.blue; + *bg_default = bg_is_default; + + return 1; +} + +// State operations +VTermState* vterm_wrapper_obtain_state(VTerm *vt) { + return vterm_obtain_state(vt); +} + +void vterm_wrapper_state_get_cursorpos(VTermState *state, int *row, int *col) { + if (!state || !row || !col) return; + + VTermPos pos; + vterm_state_get_cursorpos(state, &pos); + *row = pos.row; + *col = pos.col; +} + +void vterm_wrapper_state_get_default_colors( + VTermState *state, + int *fg_r, + int *fg_g, + int *fg_b, + int *bg_r, + int *bg_g, + int *bg_b) { + if (!state) { + if (fg_r) *fg_r = 255; + if (fg_g) *fg_g = 255; + if (fg_b) *fg_b = 255; + if (bg_r) *bg_r = 0; + if (bg_g) *bg_g = 0; + if (bg_b) *bg_b = 0; + return; + } + + VTermColor fg; + VTermColor bg; + vterm_state_get_default_colors(state, &fg, &bg); + + if (!VTERM_COLOR_IS_RGB(&fg)) { + vterm_state_convert_color_to_rgb(state, &fg); + } + if (!VTERM_COLOR_IS_RGB(&bg)) { + vterm_state_convert_color_to_rgb(state, &bg); + } + + if (fg_r) *fg_r = fg.rgb.red; + if (fg_g) *fg_g = fg.rgb.green; + if (fg_b) *fg_b = fg.rgb.blue; + if (bg_r) *bg_r = bg.rgb.red; + if (bg_g) *bg_g = bg.rgb.green; + if (bg_b) *bg_b = bg.rgb.blue; +} + +// Input operations +void vterm_wrapper_keyboard_unichar(VTerm *vt, uint32_t c, unsigned int mod) { + vterm_keyboard_unichar(vt, c, (VTermModifier)mod); +} + +void vterm_wrapper_keyboard_key(VTerm *vt, unsigned int key, unsigned int mod) { + vterm_keyboard_key(vt, (VTermKey)key, (VTermModifier)mod); +} + +void vterm_wrapper_mouse_move(VTerm *vt, int row, int col, unsigned int mod) { + vterm_mouse_move(vt, row, col, (VTermModifier)mod); +} + +void vterm_wrapper_mouse_button(VTerm *vt, int button, int pressed, unsigned int mod) { + vterm_mouse_button(vt, button, pressed, (VTermModifier)mod); +} + +// Internal callback wrappers +static int damage_wrapper(VTermRect rect, void *user) { + VTermWrapperCallbackContext *ctx = (VTermWrapperCallbackContext *)user; + if (ctx) { + ctx->damage_pending = 1; + ctx->damage_rect = rect; + } + return 1; +} + +static int movecursor_wrapper(VTermPos pos, VTermPos oldpos, int visible, void *user) { + VTermWrapperCallbackContext *ctx = (VTermWrapperCallbackContext *)user; + if (ctx) { + ctx->cursor_row = pos.row; + ctx->cursor_col = pos.col; + ctx->cursor_visible = visible; + } + return 1; +} + +void vterm_wrapper_enable_callbacks(VTermScreen *screen) { + VTermWrapperCallbackContext *ctx = (VTermWrapperCallbackContext *)vterm_screen_get_cbdata(screen); + if (ctx) { + memset(ctx, 0, sizeof(VTermWrapperCallbackContext)); + } else { + ctx = (VTermWrapperCallbackContext *)calloc(1, sizeof(VTermWrapperCallbackContext)); + if (!ctx) { + return; + } + } + + memset(&ctx->callbacks, 0, sizeof(ctx->callbacks)); + ctx->callbacks.damage = damage_wrapper; + ctx->callbacks.movecursor = movecursor_wrapper; + + vterm_screen_set_callbacks(screen, &ctx->callbacks, ctx); +} + +void vterm_wrapper_disable_callbacks(VTermScreen *screen) { + VTermWrapperCallbackContext *ctx = (VTermWrapperCallbackContext *)vterm_screen_get_cbdata(screen); + if (ctx) { + free(ctx); + } + + VTermScreenCallbacks empty_callbacks; + memset(&empty_callbacks, 0, sizeof(empty_callbacks)); + vterm_screen_set_callbacks(screen, &empty_callbacks, NULL); +} + +void vterm_wrapper_poll_callbacks( + VTermScreen *screen, + int *cursor_row, + int *cursor_col, + int *cursor_visible, + int *damage_pending, + VTermRect *damage_rect) { + VTermWrapperCallbackContext *ctx = (VTermWrapperCallbackContext *)vterm_screen_get_cbdata(screen); + if (!ctx) { + if (cursor_row) *cursor_row = -1; + if (cursor_col) *cursor_col = -1; + if (cursor_visible) *cursor_visible = -1; + if (damage_pending) *damage_pending = 0; + if (damage_rect) memset(damage_rect, 0, sizeof(VTermRect)); + return; + } + + if (cursor_row) *cursor_row = ctx->cursor_row; + if (cursor_col) *cursor_col = ctx->cursor_col; + if (cursor_visible) *cursor_visible = ctx->cursor_visible; + if (damage_pending) { + *damage_pending = ctx->damage_pending; + if (ctx->damage_pending && damage_rect) { + *damage_rect = ctx->damage_rect; + } + ctx->damage_pending = 0; + } else if (damage_rect) { + memset(damage_rect, 0, sizeof(VTermRect)); + } +} diff --git a/packages/core/src/zig/vterm_wrapper.h b/packages/core/src/zig/vterm_wrapper.h new file mode 100644 index 000000000..cc0a25722 --- /dev/null +++ b/packages/core/src/zig/vterm_wrapper.h @@ -0,0 +1,59 @@ +#ifndef VTERM_WRAPPER_H +#define VTERM_WRAPPER_H + +// Include the full libvterm header +#include + +// Wrapper functions to avoid problematic types in Zig FFI +// These functions work around Zig's @cImport limitations with opaque types + +// Core VTerm operations +VTerm* vterm_wrapper_new(int rows, int cols); +void vterm_wrapper_free(VTerm *vt); +void vterm_wrapper_set_size(VTerm *vt, int rows, int cols); +void vterm_wrapper_set_utf8(VTerm *vt, int is_utf8); +size_t vterm_wrapper_input_write(VTerm *vt, const char *bytes, size_t len); + +// Screen operations +VTermScreen* vterm_wrapper_obtain_screen(VTerm *vt); +void vterm_wrapper_screen_enable_altscreen(VTermScreen *screen, int altscreen); +void vterm_wrapper_screen_flush_damage(VTermScreen *screen); +void vterm_wrapper_screen_reset(VTermScreen *screen, int hard); +int vterm_wrapper_screen_get_cell(VTermScreen *screen, int row, int col, + uint32_t *chars, char *width, + int *bold, int *underline, int *italic, int *blink, + int *reverse, int *conceal, int *strike, + int *fg_r, int *fg_g, int *fg_b, int *fg_default, + int *bg_r, int *bg_g, int *bg_b, int *bg_default); + +// State operations +VTermState* vterm_wrapper_obtain_state(VTerm *vt); + +// Input operations +void vterm_wrapper_keyboard_unichar(VTerm *vt, uint32_t c, unsigned int mod); +void vterm_wrapper_keyboard_key(VTerm *vt, unsigned int key, unsigned int mod); +void vterm_wrapper_mouse_move(VTerm *vt, int row, int col, unsigned int mod); +void vterm_wrapper_mouse_button(VTerm *vt, int button, int pressed, unsigned int mod); + +// State queries +void vterm_wrapper_state_get_cursorpos(VTermState *state, int *row, int *col); +void vterm_wrapper_state_get_default_colors( + VTermState *state, + int *fg_r, + int *fg_g, + int *fg_b, + int *bg_r, + int *bg_g, + int *bg_b); + +void vterm_wrapper_enable_callbacks(VTermScreen *screen); +void vterm_wrapper_disable_callbacks(VTermScreen *screen); +void vterm_wrapper_poll_callbacks( + VTermScreen *screen, + int *cursor_row, + int *cursor_col, + int *cursor_visible, + int *damage_pending, + VTermRect *damage_rect); + +#endif // VTERM_WRAPPER_H diff --git a/packages/solid/examples/tmux-4-pane.tsx b/packages/solid/examples/tmux-4-pane.tsx new file mode 100755 index 000000000..40485886e --- /dev/null +++ b/packages/solid/examples/tmux-4-pane.tsx @@ -0,0 +1,303 @@ +#!/usr/bin/env bun + +import { createSignal, onMount, createEffect } from "solid-js" +import { render, useRenderer, useTerminalDimensions } from "../index" + +function TmuxFourPane() { + const renderer = useRenderer() + const dim = useTerminalDimensions() + + // Use default terminal size if not available yet + const cols = () => dim().width + const rows = () => dim().height + + // Resizable splits - start at 50/50 + const [verticalSplit, setVerticalSplit] = createSignal(40) + const [horizontalSplit, setHorizontalSplit] = createSignal(12) + const [isDragging, setIsDragging] = createSignal<"horizontal" | "vertical" | null>(null) + const [dragOffset, setDragOffset] = createSignal({ x: 0, y: 0 }) + const [isResizing, setIsResizing] = createSignal(false) + const [focusedPane, setFocusedPane] = createSignal<1 | 2 | 3 | 4>(1) + + onMount(() => { + // Update splits when renderer is ready + setVerticalSplit(Math.floor(cols() / 2)) + setHorizontalSplit(Math.floor(rows() / 2)) + }) + + // Calculate pane dimensions + const leftWidth = () => verticalSplit() + const rightX = () => verticalSplit() + 1 + const rightWidth = () => cols() - rightX() + const topHeight = () => horizontalSplit() + const bottomY = () => horizontalSplit() + 1 + const bottomHeight = () => rows() - bottomY() + + // Calculate inner terminal dimensions (account for box borders) + const innerLeftWidth = () => Math.max(10, leftWidth() - 2) + const innerRightWidth = () => Math.max(10, rightWidth() - 2) + const innerTopHeight = () => Math.max(5, topHeight() - 2) + const innerBottomHeight = () => Math.max(5, bottomHeight() - 2) + + // Mouse handlers for dividers + const handleVerticalMouseDown = (event: any) => { + setIsDragging("vertical") + setIsResizing(true) + } + + const handleHorizontalMouseDown = (event: any) => { + setIsDragging("horizontal") + setIsResizing(true) + } + + const handleVerticalMouseDrag = (event: any) => { + if (isDragging() === "vertical") { + const constrainedX = Math.max(10, Math.min(cols() - 10, event.x)) + setVerticalSplit(constrainedX) + } + } + + const handleHorizontalMouseDrag = (event: any) => { + if (isDragging() === "horizontal") { + const constrainedY = Math.max(5, Math.min(rows() - 5, event.y)) + setHorizontalSplit(constrainedY) + } + } + + const handleMouseUp = () => { + setIsDragging(null) + setIsResizing(false) + } + + const handleMouseDragEnd = () => { + setIsDragging(null) + setIsResizing(false) + } + + // Terminal refs + let term1: any, term2: any, term3: any, term4: any + + // Handle pane focus + const focusPane = (pane: 1 | 2 | 3 | 4) => { + setFocusedPane(pane) + // Focus the appropriate terminal + const terminals = [term1, term2, term3, term4] + const term = terminals[pane - 1] + if (term && term.focus) { + term.focus() + } + } + + return ( + <> + {/* Top Left Pane */} + focusPane(1)} + > + + (term1 = r)} + width={innerLeftWidth() - 1} + height={innerTopHeight() - 1} + cols={innerLeftWidth() - 1} + rows={innerTopHeight() - 1} + shell="bash" + showBorder={false} + backgroundColor="#000000" + autoFocus={focusedPane() === 1} + /> + + + + {/* Top Right Pane */} + focusPane(2)} + > + + (term2 = r)} + width={innerRightWidth() - 1} + height={innerTopHeight() - 1} + cols={innerRightWidth() - 1} + rows={innerTopHeight() - 1} + shell="bash" + showBorder={false} + backgroundColor="#000000" + autoFocus={focusedPane() === 2} + /> + + + + {/* Bottom Left Pane */} + focusPane(3)} + > + + (term3 = r)} + width={innerLeftWidth() - 1} + height={innerBottomHeight() - 1} + cols={innerLeftWidth() - 1} + rows={innerBottomHeight() - 1} + shell="bash" + showBorder={false} + backgroundColor="#000000" + autoFocus={focusedPane() === 3} + /> + + + + {/* Bottom Right Pane */} + focusPane(4)} + > + + (term4 = r)} + width={innerRightWidth() - 1} + height={innerBottomHeight() - 1} + cols={innerRightWidth() - 1} + rows={innerBottomHeight() - 1} + shell="bash" + showBorder={false} + backgroundColor="#000000" + autoFocus={focusedPane() === 4} + /> + + + + {/* Vertical Divider - Hit Area */} + + {/* Vertical Divider - Visual */} + + + {/* Horizontal Divider - Hit Area */} + + {/* Horizontal Divider - Visual */} + + + {/* Center cross section where dividers meet */} + + + ) +} + +// Main entry point +async function main() { + try { + // Load the libvterm-enabled library if available + const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : process.arch + const os = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux" + const ext = process.platform === "darwin" ? "dylib" : process.platform === "win32" ? "dll" : "so" + const libPath = new URL(`../../core/src/zig/lib/${arch}-${os}/libopentui.${ext}`, import.meta.url).pathname + + const { setRenderLibPath } = await import("@opentui/core") + setRenderLibPath(libPath) + console.log("✅ tmux-like 4-pane terminal with libvterm") + console.log("Click and drag the dividers to resize panes!") + } catch (e) { + console.warn("⚠️ Could not load libvterm library, using fallback") + } + + // Start the app + await render(() => , { + exitOnCtrlC: true, + targetFps: 30, + }) + + process.on("SIGINT", () => { + process.exit(0) + }) +} + +if (import.meta.main) { + main().catch(console.error) +} diff --git a/packages/solid/jsx-runtime.d.ts b/packages/solid/jsx-runtime.d.ts index f646c0344..42ab81692 100644 --- a/packages/solid/jsx-runtime.d.ts +++ b/packages/solid/jsx-runtime.d.ts @@ -9,6 +9,7 @@ import type { SelectProps, SpanProps, TabSelectProps, + TerminalProps, TextProps, } from "./src/types/elements" import type { DomNode } from "./dist" @@ -28,6 +29,7 @@ declare namespace JSX { ascii_font: AsciiFontProps tab_select: TabSelectProps scrollbox: ScrollBoxProps + terminal: TerminalProps b: SpanProps strong: SpanProps diff --git a/packages/solid/src/elements/index.ts b/packages/solid/src/elements/index.ts index d9f3e47b8..63fa929ad 100644 --- a/packages/solid/src/elements/index.ts +++ b/packages/solid/src/elements/index.ts @@ -8,6 +8,7 @@ import { TextAttributes, TextNodeRenderable, TextRenderable, + TerminalRenderer, type RenderContext, type TextNodeOptions, } from "@opentui/core" @@ -80,6 +81,7 @@ export const baseComponents = { ascii_font: ASCIIFontRenderable, tab_select: TabSelectRenderable, scrollbox: ScrollBoxRenderable, + terminal: TerminalRenderer, span: SpanRenderable, strong: BoldSpanRenderable, diff --git a/packages/solid/src/types/elements.ts b/packages/solid/src/types/elements.ts index 7cb39ee1d..dc30b6cb3 100644 --- a/packages/solid/src/types/elements.ts +++ b/packages/solid/src/types/elements.ts @@ -16,6 +16,8 @@ import type { TabSelectOption, TabSelectRenderable, TabSelectRenderableOptions, + TerminalRenderer, + TerminalRendererOptions, TextNodeRenderable, TextOptions, TextRenderable, @@ -130,6 +132,11 @@ export type ScrollBoxProps = ComponentProps, Sc stickyStart?: "bottom" | "top" | "left" | "right" } +export type TerminalProps = ComponentProps & { + focused?: boolean + onData?: (data: string) => void +} + // ============================================================================ // Extended/Dynamic Component System // ============================================================================