import { describe, expect, it } from "vite-plus/test"; import { GHOSTTY_CELL_WIDE, type GhosttyCell, type GhosttySnapshot } from "./core"; import { ghosttyTextRunEnd, measureGhosttyCell, renderGhosttySnapshot, terminalGridSize, } from "./renderer"; const cell = (text: string, wide = 0): GhosttyCell => ({ text, wide, foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 }, bold: false, italic: false, invisible: false, strikethrough: false, overline: false, underline: false, selected: false, }); describe("terminalGridSize", () => { it("matches the mobile renderer's cell-and-padding sizing model", () => { expect(terminalGridSize(808, 408, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({ cols: 80, rows: 20, }); }); it("never sends an invalid zero-sized terminal to libghostty", () => { expect(terminalGridSize(0, 0, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({ cols: 1, rows: 1, }); }); }); describe("measureGhosttyCell", () => { it("uses descender-aware metrics and the mobile terminal line-height", () => { const measureText = (text: string) => text === "M" ? { width: 7.2, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 0 } : { width: 14.4, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 3 }; const context = { font: "", measureText, } as unknown as CanvasRenderingContext2D; expect(measureGhosttyCell(context, 12, "monospace")).toEqual({ width: 7.2, height: 16, baseline: 11, }); }); }); describe("ghosttyTextRunEnd", () => { it("includes wide spacer tails in the visual clip without rendering spaces", () => { const cells = [ cell("界", GHOSTTY_CELL_WIDE.wide), cell("", GHOSTTY_CELL_WIDE.spacerTail), cell("🙂", GHOSTTY_CELL_WIDE.wide), cell("", GHOSTTY_CELL_WIDE.spacerTail), cell(""), ]; expect(ghosttyTextRunEnd(cells, 0, () => true)).toBe(4); }); }); describe("renderGhosttySnapshot", () => { it("underlines every cell in a hovered wrapped link", () => { const fillRectCalls: number[][] = []; const context = { canvas: { width: 200, height: 80 }, beginPath: () => {}, clip: () => {}, fillRect: (...args: number[]) => fillRectCalls.push(args), fillText: () => {}, rect: () => {}, resetTransform: () => {}, restore: () => {}, save: () => {}, set fillStyle(_value: string) {}, set font(_value: string) {}, set textBaseline(_value: string) {}, } as unknown as CanvasRenderingContext2D; const snapshot: GhosttySnapshot = { cols: 4, rows: 2, foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 }, cursor: { r: 255, g: 255, b: 255 }, cursorX: -1, cursorY: -1, cursorVisible: false, cursorBlinking: false, cursorStyle: 1, dirtyRows: new Set([0, 1]), rowData: [0, 1].map(() => ({ cells: [cell("a"), cell("b"), cell("c"), cell("d")], text: "abcd", isWrapContinuation: false, wrapsToNext: false, })), }; renderGhosttySnapshot({ context, snapshot, metrics: { width: 10, height: 20, baseline: 15 }, fontSize: 12, fontFamily: "monospace", padding: 4, forceFull: false, cursorOn: false, hoveredLinkRange: { start: { x: 2, y: 0 }, end: { x: 1, y: 1 } }, }); expect(fillRectCalls.filter(([, , , height]) => height === 1)).toEqual([ [24, 22, 10, 1], [34, 22, 10, 1], [4, 42, 10, 1], [14, 42, 10, 1], ]); }); it("constrains text runs and cursor glyphs to their terminal cells", () => { const fillTextCalls: unknown[][] = []; const context = { canvas: { width: 200, height: 40 }, beginPath: () => {}, clip: () => {}, fillRect: () => {}, fillText: (...args: unknown[]) => fillTextCalls.push(args), rect: () => {}, resetTransform: () => {}, restore: () => {}, save: () => {}, set fillStyle(_value: string) {}, set font(_value: string) {}, set textBaseline(_value: string) {}, } as unknown as CanvasRenderingContext2D; const cells = [cell("a"), cell("b"), cell("x")]; const snapshot: GhosttySnapshot = { cols: 3, rows: 1, foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 }, cursor: { r: 255, g: 255, b: 255 }, cursorX: 2, cursorY: 0, cursorVisible: true, cursorBlinking: false, cursorStyle: 1, dirtyRows: new Set([0]), rowData: [{ cells, text: "abx", isWrapContinuation: false, wrapsToNext: false }], }; renderGhosttySnapshot({ context, snapshot, metrics: { width: 7.2, height: 16, baseline: 11 }, fontSize: 12, fontFamily: "monospace", padding: 4, forceFull: false, cursorOn: true, }); expect(fillTextCalls).toEqual([ ["abx", 4, 15, 21.6], ["x", 18.4, 15, 7.2], ]); }); it("repaints the cell without an overlay during the blink off phase", () => { const fillTextCalls: unknown[][] = []; const context = { canvas: { width: 200, height: 40 }, beginPath: () => {}, clip: () => {}, fillRect: () => {}, fillText: (...args: unknown[]) => fillTextCalls.push(args), rect: () => {}, resetTransform: () => {}, restore: () => {}, save: () => {}, set fillStyle(_value: string) {}, set font(_value: string) {}, set textBaseline(_value: string) {}, } as unknown as CanvasRenderingContext2D; const snapshot: GhosttySnapshot = { cols: 3, rows: 1, foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 }, cursor: { r: 255, g: 255, b: 255 }, cursorX: 2, cursorY: 0, cursorVisible: true, cursorBlinking: true, cursorStyle: 1, dirtyRows: new Set(), rowData: [ { cells: [cell("a"), cell("b"), cell("x")], text: "abx", isWrapContinuation: false, wrapsToNext: false, }, ], }; renderGhosttySnapshot({ context, snapshot, metrics: { width: 7.2, height: 16, baseline: 11 }, fontSize: 12, fontFamily: "monospace", padding: 4, forceFull: false, cursorOn: false, }); // The cursor row still repaints so the block disappears, but the inverted // glyph the on phase draws over the cell is gone. expect(fillTextCalls).toEqual([["abx", 4, 15, 21.6]]); }); it("repaints the previous cursor row after the cursor moves", () => { const clearedRows: number[] = []; const context = { canvas: { width: 200, height: 80 }, beginPath: () => {}, clip: () => {}, fillRect: (_left: number, top: number, _width: number, height: number) => { if (height === 16) clearedRows.push(top); }, fillText: () => {}, rect: () => {}, resetTransform: () => {}, restore: () => {}, save: () => {}, set fillStyle(_value: string) {}, set font(_value: string) {}, set textBaseline(_value: string) {}, } as unknown as CanvasRenderingContext2D; const snapshot: GhosttySnapshot = { cols: 1, rows: 3, foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 }, cursor: { r: 255, g: 255, b: 255 }, cursorX: 0, cursorY: 2, cursorVisible: true, cursorBlinking: false, cursorStyle: 1, dirtyRows: new Set(), rowData: [0, 1, 2].map(() => ({ cells: [cell("")], text: "", isWrapContinuation: false, wrapsToNext: false, })), }; renderGhosttySnapshot({ context, snapshot, metrics: { width: 7.2, height: 16, baseline: 11 }, fontSize: 12, fontFamily: "monospace", padding: 4, forceFull: false, cursorOn: true, previousCursorY: 0, }); expect(clearedRows).toEqual([4, 36, 36]); }); });