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]); });});
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]);
});
});