UNPKG

@opentui/core

Version:

OpenTUI is a TypeScript library for building terminal user interfaces (TUIs)

669 lines (663 loc) 19.7 kB
// @bun import { ANSI, CliRenderer, TreeSitterClient, resolveRenderLib } from "./index-h3dbfsf6.js"; // src/testing/test-renderer.ts import { Readable } from "stream"; // src/testing/mock-keys.ts var KeyCodes = { RETURN: "\r", LINEFEED: ` `, TAB: "\t", BACKSPACE: "\b", DELETE: "\x1B[3~", HOME: "\x1B[H", END: "\x1B[F", ESCAPE: "\x1B", ARROW_UP: "\x1B[A", ARROW_DOWN: "\x1B[B", ARROW_RIGHT: "\x1B[C", ARROW_LEFT: "\x1B[D", F1: "\x1BOP", F2: "\x1BOQ", F3: "\x1BOR", F4: "\x1BOS", F5: "\x1B[15~", F6: "\x1B[17~", F7: "\x1B[18~", F8: "\x1B[19~", F9: "\x1B[20~", F10: "\x1B[21~", F11: "\x1B[23~", F12: "\x1B[24~" }; var kittyKeyCodeMap = { escape: 27, tab: 9, return: 13, backspace: 127, insert: 57348, delete: 57349, left: 57350, right: 57351, up: 57352, down: 57353, pageup: 57354, pagedown: 57355, home: 57356, end: 57357, f1: 57364, f2: 57365, f3: 57366, f4: 57367, f5: 57368, f6: 57369, f7: 57370, f8: 57371, f9: 57372, f10: 57373, f11: 57374, f12: 57375 }; function encodeKittySequence(codepoint, modifiers) { let modMask = 0; if (modifiers?.shift) modMask |= 1; if (modifiers?.meta) modMask |= 2; if (modifiers?.ctrl) modMask |= 4; if (modifiers?.super) modMask |= 8; if (modifiers?.hyper) modMask |= 16; if (modMask === 0) { return `\x1B[${codepoint}u`; } else { return `\x1B[${codepoint};${modMask + 1}u`; } } function encodeModifyOtherKeysSequence(charCode, modifiers) { let modMask = 0; if (modifiers?.shift) modMask |= 1; if (modifiers?.meta) modMask |= 2; if (modifiers?.ctrl) modMask |= 4; if (modifiers?.super) modMask |= 8; if (modifiers?.hyper) modMask |= 16; if (modMask === 0) { return String.fromCharCode(charCode); } return `\x1B[27;${modMask + 1};${charCode}~`; } function resolveKeyInput(key) { let keyValue; let keyName; if (typeof key === "string") { if (key in KeyCodes) { keyValue = KeyCodes[key]; keyName = key.toLowerCase(); } else { keyValue = key; keyName = undefined; } } else { keyValue = KeyCodes[key]; if (!keyValue) { throw new Error(`Unknown key: ${key}`); } keyName = String(key).toLowerCase(); } return { keyValue, keyName }; } function createMockKeys(renderer, options) { const useKittyKeyboard = options?.kittyKeyboard ?? false; const useOtherModifiersMode = options?.otherModifiersMode ?? false; const effectiveOtherModifiersMode = useOtherModifiersMode && !useKittyKeyboard; const pressKeys = async (keys, delayMs = 0) => { for (const key of keys) { const { keyValue: keyCode } = resolveKeyInput(key); renderer.stdin.emit("data", Buffer.from(keyCode)); if (delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } } }; const pressKey = (key, modifiers) => { if (useKittyKeyboard) { let { keyValue, keyName } = resolveKeyInput(key); const valueToKeyNameMap = { "\b": "backspace", "\r": "return", "\n": "return", "\t": "tab", "\x1B": "escape", "\x1B[A": "up", "\x1B[B": "down", "\x1B[C": "right", "\x1B[D": "left", "\x1B[H": "home", "\x1B[F": "end", "\x1B[3~": "delete" }; if (keyValue && valueToKeyNameMap[keyValue]) { keyName = valueToKeyNameMap[keyValue]; } if (keyName && keyName.startsWith("arrow_")) { keyName = keyName.substring(6); } if (keyName && kittyKeyCodeMap[keyName]) { const kittyCode = kittyKeyCodeMap[keyName]; const sequence = encodeKittySequence(kittyCode, modifiers); renderer.stdin.emit("data", Buffer.from(sequence)); return; } if (keyValue && keyValue.length === 1 && !keyValue.startsWith("\x1B")) { const codepoint = keyValue.codePointAt(0); if (codepoint) { const sequence = encodeKittySequence(codepoint, modifiers); renderer.stdin.emit("data", Buffer.from(sequence)); return; } } } if (effectiveOtherModifiersMode && modifiers) { let { keyValue, keyName } = resolveKeyInput(key); const valueToCharCodeMap = { "\b": 127, "\r": 13, "\n": 13, "\t": 9, "\x1B": 27, " ": 32 }; let charCode; if (keyValue && valueToCharCodeMap[keyValue] !== undefined) { charCode = valueToCharCodeMap[keyValue]; } else if (keyValue && keyValue.length === 1 && !keyValue.startsWith("\x1B")) { charCode = keyValue.charCodeAt(0); } if (charCode !== undefined) { const sequence = encodeModifyOtherKeysSequence(charCode, modifiers); renderer.stdin.emit("data", Buffer.from(sequence)); return; } } let keyCode = resolveKeyInput(key).keyValue; if (modifiers) { if (keyCode.startsWith("\x1B[") && keyCode.length > 2) { const modifier = 1 + (modifiers.shift ? 1 : 0) + (modifiers.meta ? 2 : 0) + (modifiers.ctrl ? 4 : 0) + (modifiers.super ? 8 : 0) + (modifiers.hyper ? 16 : 0); if (modifier > 1) { const tildeMatch = keyCode.match(/^\x1b\[(\d+)~$/); if (tildeMatch) { keyCode = `\x1B[${tildeMatch[1]};${modifier}~`; } else { const ending = keyCode.slice(-1); keyCode = `\x1B[1;${modifier}${ending}`; } } } else if (keyCode.length === 1) { let char = keyCode; if (char === "\b" && (modifiers.ctrl || modifiers.super || modifiers.hyper)) { const modifier = 1 + (modifiers.shift ? 1 : 0) + (modifiers.meta ? 2 : 0) + (modifiers.ctrl ? 4 : 0) + (modifiers.super ? 8 : 0) + (modifiers.hyper ? 16 : 0); keyCode = `\x1B[27;${modifier};127~`; } else if (modifiers.ctrl) { if (char >= "a" && char <= "z") { keyCode = String.fromCharCode(char.charCodeAt(0) - 96); } else if (char >= "A" && char <= "Z") { keyCode = String.fromCharCode(char.charCodeAt(0) - 64); } else { const specialCtrlMap = { "[": "\x1B", "\\": "\x1C", "]": "\x1D", "^": "\x1E", _: "\x1F", "?": "\x7F", "/": "\x1F", "-": "\x1F", ".": "\x1E", ",": "\x1C", "@": "\x00", " ": "\x00" }; if (char in specialCtrlMap) { keyCode = specialCtrlMap[char]; } } if (modifiers.meta) { keyCode = `\x1B${keyCode}`; } } else { if (modifiers.shift && char >= "a" && char <= "z") { char = char.toUpperCase(); } if (modifiers.meta) { keyCode = `\x1B${char}`; } else { keyCode = char; } } } else if (modifiers.meta && !keyCode.startsWith("\x1B")) { keyCode = `\x1B${keyCode}`; } } renderer.stdin.emit("data", Buffer.from(keyCode)); }; const typeText = async (text, delayMs = 0) => { const keys = text.split(""); await pressKeys(keys, delayMs); }; const pressReturn = (modifiers) => { pressKey(KeyCodes.RETURN, modifiers); }; const pressEscape = (modifiers) => { pressKey(KeyCodes.ESCAPE, modifiers); }; const pressTab = (modifiers) => { pressKey(KeyCodes.TAB, modifiers); }; const pressBackspace = (modifiers) => { pressKey(KeyCodes.BACKSPACE, modifiers); }; const pressArrow = (direction, modifiers) => { const keyMap = { up: KeyCodes.ARROW_UP, down: KeyCodes.ARROW_DOWN, left: KeyCodes.ARROW_LEFT, right: KeyCodes.ARROW_RIGHT }; pressKey(keyMap[direction], modifiers); }; const pressCtrlC = () => { pressKey("c", { ctrl: true }); }; const pasteBracketedText = (text) => { return pressKeys([ANSI.bracketedPasteStart, text, ANSI.bracketedPasteEnd]); }; return { pressKeys, pressKey, typeText, pressEnter: pressReturn, pressEscape, pressTab, pressBackspace, pressArrow, pressCtrlC, pasteBracketedText }; } // src/testing/mock-mouse.ts var MouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 2, WHEEL_UP: 64, WHEEL_DOWN: 65, WHEEL_LEFT: 66, WHEEL_RIGHT: 67 }; function createMockMouse(renderer) { let currentPosition = { x: 0, y: 0 }; let buttonsPressed = new Set; const generateMouseEvent = (type, x, y, button = MouseButtons.LEFT, modifiers = {}) => { let buttonCode = button; if (modifiers.shift) buttonCode |= 4; if (modifiers.alt) buttonCode |= 8; if (modifiers.ctrl) buttonCode |= 16; switch (type) { case "move": buttonCode = 32 | 3; if (modifiers.shift) buttonCode |= 4; if (modifiers.alt) buttonCode |= 8; if (modifiers.ctrl) buttonCode |= 16; break; case "drag": buttonCode = (buttonsPressed.size > 0 ? Array.from(buttonsPressed)[0] : button) | 32; if (modifiers.shift) buttonCode |= 4; if (modifiers.alt) buttonCode |= 8; if (modifiers.ctrl) buttonCode |= 16; break; case "scroll": break; } const ansiX = x + 1; const ansiY = y + 1; let pressRelease = "M"; if (type === "up" || type === "move" || type === "drag") { pressRelease = "m"; } return `\x1B[<${buttonCode};${ansiX};${ansiY}${pressRelease}`; }; const emitMouseEvent = async (type, x, y, button = MouseButtons.LEFT, options = {}) => { const { modifiers = {}, delayMs = 0 } = options; const eventSequence = generateMouseEvent(type, x, y, button, modifiers); renderer.stdin.emit("data", Buffer.from(eventSequence)); currentPosition = { x, y }; if (type === "down" && button < 64) { buttonsPressed.add(button); } else if (type === "up") { buttonsPressed.delete(button); } if (delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } }; const moveTo = async (x, y, options = {}) => { const { button = MouseButtons.LEFT, delayMs = 0, modifiers = {} } = options; if (buttonsPressed.size > 0) { await emitMouseEvent("drag", x, y, Array.from(buttonsPressed)[0], { modifiers, delayMs }); } else { await emitMouseEvent("move", x, y, button, { modifiers, delayMs }); } currentPosition = { x, y }; }; const click = async (x, y, button = MouseButtons.LEFT, options = {}) => { const { delayMs = 10, modifiers = {} } = options; await emitMouseEvent("down", x, y, button, { modifiers, delayMs }); await new Promise((resolve) => setTimeout(resolve, delayMs)); await emitMouseEvent("up", x, y, button, { modifiers, delayMs }); }; const doubleClick = async (x, y, button = MouseButtons.LEFT, options = {}) => { const { delayMs = 10, modifiers = {} } = options; await click(x, y, button, { modifiers, delayMs }); await new Promise((resolve) => setTimeout(resolve, delayMs)); await click(x, y, button, { modifiers, delayMs }); }; const pressDown = async (x, y, button = MouseButtons.LEFT, options = {}) => { const { modifiers = {}, delayMs = 0 } = options; await emitMouseEvent("down", x, y, button, { modifiers, delayMs }); }; const release = async (x, y, button = MouseButtons.LEFT, options = {}) => { const { modifiers = {}, delayMs = 0 } = options; await emitMouseEvent("up", x, y, button, { modifiers, delayMs }); }; const drag = async (startX, startY, endX, endY, button = MouseButtons.LEFT, options = {}) => { const { delayMs = 10, modifiers = {} } = options; await pressDown(startX, startY, button, { modifiers }); const steps = 5; const dx = (endX - startX) / steps; const dy = (endY - startY) / steps; for (let i = 1;i <= steps; i++) { const currentX = Math.round(startX + dx * i); const currentY = Math.round(startY + dy * i); await emitMouseEvent("drag", currentX, currentY, button, { modifiers, delayMs }); } await release(endX, endY, button, { modifiers }); }; const scroll = async (x, y, direction, options = {}) => { const { modifiers = {}, delayMs = 0 } = options; let button; switch (direction) { case "up": button = MouseButtons.WHEEL_UP; break; case "down": button = MouseButtons.WHEEL_DOWN; break; case "left": button = MouseButtons.WHEEL_LEFT; break; case "right": button = MouseButtons.WHEEL_RIGHT; break; } await emitMouseEvent("scroll", x, y, button, { modifiers, delayMs }); }; const getCurrentPosition = () => { return { ...currentPosition }; }; const getPressedButtons = () => { return Array.from(buttonsPressed); }; return { moveTo, click, doubleClick, pressDown, release, drag, scroll, getCurrentPosition, getPressedButtons, emitMouseEvent }; } // src/testing/test-renderer.ts var decoder = new TextDecoder; async function createTestRenderer(options) { process.env.OTUI_USE_CONSOLE = "false"; const useKittyKeyboard = options.kittyKeyboard ? { events: true } : options.useKittyKeyboard; const renderer = await setupTestRenderer({ ...options, useKittyKeyboard, useAlternateScreen: false, useConsole: false }); renderer.disableStdoutInterception(); const mockInput = createMockKeys(renderer, { kittyKeyboard: options.kittyKeyboard, otherModifiersMode: options.otherModifiersMode }); const mockMouse = createMockMouse(renderer); const renderOnce = async () => { await renderer.loop(); }; return { renderer, mockInput, mockMouse, renderOnce, captureCharFrame: () => { const currentBuffer = renderer.currentRenderBuffer; const frameBytes = currentBuffer.getRealCharBytes(true); return decoder.decode(frameBytes); }, captureSpans: () => { const currentBuffer = renderer.currentRenderBuffer; const lines = currentBuffer.getSpanLines(); const cursorState = renderer.getCursorState(); return { cols: currentBuffer.width, rows: currentBuffer.height, cursor: [cursorState.x, cursorState.y], lines }; }, resize: (width, height) => { renderer.processResize(width, height); } }; } async function setupTestRenderer(config) { const stdin = config.stdin || new Readable({ read() {} }); const stdout = config.stdout || process.stdout; const width = config.width || stdout.columns || 80; const height = config.height || stdout.rows || 24; const renderHeight = config.experimental_splitHeight && config.experimental_splitHeight > 0 ? config.experimental_splitHeight : height; const ziglib = resolveRenderLib(); const rendererPtr = ziglib.createRenderer(width, renderHeight, { testing: true, remote: config.remote ?? false }); if (!rendererPtr) { throw new Error("Failed to create test renderer"); } if (config.useThread === undefined) { config.useThread = true; } if (process.platform === "linux") { config.useThread = false; } ziglib.setUseThread(rendererPtr, config.useThread); const renderer = new CliRenderer(ziglib, rendererPtr, stdin, stdout, width, height, config); process.off("SIGWINCH", renderer["sigwinchHandler"]); return renderer; } // src/testing/mock-tree-sitter-client.ts class MockTreeSitterClient extends TreeSitterClient { _highlightPromises = []; _mockResult = { highlights: [] }; _autoResolveTimeout; constructor(options) { super({ dataPath: "/tmp/mock" }); this._autoResolveTimeout = options?.autoResolveTimeout; } async highlightOnce(content, filetype) { const { promise, resolve } = Promise.withResolvers(); let timeout; if (this._autoResolveTimeout !== undefined) { timeout = setTimeout(() => { const index = this._highlightPromises.findIndex((p) => p.promise === promise); if (index !== -1) { resolve(this._mockResult); this._highlightPromises.splice(index, 1); } }, this._autoResolveTimeout); } this._highlightPromises.push({ promise, resolve, timeout }); return promise; } setMockResult(result) { this._mockResult = result; } resolveHighlightOnce(index = 0) { if (index >= 0 && index < this._highlightPromises.length) { const item = this._highlightPromises[index]; if (item.timeout) { clearTimeout(item.timeout); } item.resolve(this._mockResult); this._highlightPromises.splice(index, 1); } } resolveAllHighlightOnce() { for (const { resolve, timeout } of this._highlightPromises) { if (timeout) { clearTimeout(timeout); } resolve(this._mockResult); } this._highlightPromises = []; } isHighlighting() { return this._highlightPromises.length > 0; } } // src/testing/spy.ts function createSpy() { const calls = []; const spy = (...args) => { calls.push(args); }; spy.calls = calls; spy.callCount = () => calls.length; spy.calledWith = (...expected) => { return calls.some((call) => JSON.stringify(call) === JSON.stringify(expected)); }; spy.reset = () => calls.length = 0; return spy; } // src/testing/test-recorder.ts class TestRecorder { renderer; frames = []; recording = false; frameNumber = 0; startTime = 0; originalRenderNative; decoder = new TextDecoder; recordBuffers; constructor(renderer, options) { this.renderer = renderer; this.recordBuffers = options?.recordBuffers || {}; } rec() { if (this.recording) { return; } this.recording = true; this.frames = []; this.frameNumber = 0; this.startTime = Date.now(); this.originalRenderNative = this.renderer["renderNative"].bind(this.renderer); this.renderer["renderNative"] = () => { this.originalRenderNative(); this.captureFrame(); }; } stop() { if (!this.recording) { return; } this.recording = false; if (this.originalRenderNative) { this.renderer["renderNative"] = this.originalRenderNative; this.originalRenderNative = undefined; } } get recordedFrames() { return [...this.frames]; } clear() { this.frames = []; this.frameNumber = 0; } get isRecording() { return this.recording; } captureFrame() { const currentBuffer = this.renderer.currentRenderBuffer; const frameBytes = currentBuffer.getRealCharBytes(true); const frame = this.decoder.decode(frameBytes); const recordedFrame = { frame, timestamp: Date.now() - this.startTime, frameNumber: this.frameNumber++ }; if (this.recordBuffers.fg || this.recordBuffers.bg || this.recordBuffers.attributes) { const buffers = currentBuffer.buffers; recordedFrame.buffers = {}; if (this.recordBuffers.fg) { recordedFrame.buffers.fg = new Float32Array(buffers.fg); } if (this.recordBuffers.bg) { recordedFrame.buffers.bg = new Float32Array(buffers.bg); } if (this.recordBuffers.attributes) { recordedFrame.buffers.attributes = new Uint8Array(buffers.attributes); } } this.frames.push(recordedFrame); } } export { createTestRenderer, createSpy, createMockMouse, createMockKeys, TestRecorder, MouseButtons, MockTreeSitterClient, KeyCodes }; //# debugId=CB79F086C6FCC24864756E2164756E21 //# sourceMappingURL=testing.js.map