Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/web/src/terminal/ghostty/renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,62 @@ describe("ghosttyTextRunEnd", () => {
});

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 = {
Expand Down
21 changes: 19 additions & 2 deletions apps/web/src/terminal/ghostty/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export interface GhosttyCellMetrics {
readonly baseline: number;
}

export interface GhosttyCellRange {
readonly start: { readonly x: number; readonly y: number };
readonly end: { readonly x: number; readonly y: number };
}

const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)";

function cssColor(color: GhosttyColor): string {
Expand Down Expand Up @@ -98,6 +103,7 @@ export function renderGhosttySnapshot(options: {
readonly previousCursorY?: number | null;
readonly focused?: boolean;
readonly selectionBackground?: string;
readonly hoveredLinkRange?: GhosttyCellRange | null;
/** Vertical origin of row 0; defaults to the horizontal padding. */
readonly originY?: number;
}): void {
Expand All @@ -114,6 +120,7 @@ export function renderGhosttySnapshot(options: {
} = options;
const focused = options.focused ?? true;
const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND;
const hoveredLinkRange = options.hoveredLinkRange ?? null;
const originY = options.originY ?? padding;
const rowsToDraw = forceFull
? Array.from({ length: snapshot.rows }, (_, index) => index)
Expand Down Expand Up @@ -216,10 +223,20 @@ export function renderGhosttySnapshot(options: {

for (let column = 0; column < row.cells.length; column += 1) {
const cell = row.cells[column];
if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline)) continue;
const hoveredLink =
hoveredLinkRange !== null &&
rowIndex >= hoveredLinkRange.start.y &&
rowIndex <= hoveredLinkRange.end.y &&
(rowIndex > hoveredLinkRange.start.y || column >= hoveredLinkRange.start.x) &&
(rowIndex < hoveredLinkRange.end.y || column <= hoveredLinkRange.end.x);
if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline && !hoveredLink)) {
continue;
}
context.fillStyle = cssColor(cell.foreground);
const left = padding + column * metrics.width;
if (cell.underline) context.fillRect(left, top + metrics.height - 2, metrics.width, 1);
if (cell.underline || hoveredLink) {
context.fillRect(left, top + metrics.height - 2, metrics.width, 1);
}
if (cell.strikethrough) {
context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1);
}
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ import {
isTerminalPasteShortcut,
shouldBlinkTerminalCursor,
shouldReportTerminalMouse,
shouldShowTerminalLinkHover,
terminalGridCellAt,
terminalScrollbarGeometry,
terminalScrollbarOffsetAtPointer,
terminalLinkAtColumn,
terminalLinkAtPosition,
terminalLinkAtPositionWithRange,
terminalContentOriginY,
terminalFontFamily,
fittedTerminalFontSize,
Expand Down Expand Up @@ -56,6 +59,32 @@ describe("isTerminalAltGraphText", () => {
});
});

describe("terminalGridCellAt", () => {
const options = {
bounds: { left: 100, top: 200 },
cols: 3,
rows: 2,
metrics: { width: 10, height: 20 },
padding: 4,
originY: 24,
};

it("maps points inside the rendered grid without clamping its padding", () => {
expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 224 })).toEqual({
x: 0,
y: 0,
});
expect(terminalGridCellAt({ ...options, clientX: 133, clientY: 263 })).toEqual({
x: 2,
y: 1,
});
expect(terminalGridCellAt({ ...options, clientX: 103, clientY: 224 })).toBeNull();
expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 223 })).toBeNull();
expect(terminalGridCellAt({ ...options, clientX: 134, clientY: 224 })).toBeNull();
expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 264 })).toBeNull();
});
});

describe("shouldBlinkTerminalCursor", () => {
const blinking = {
focused: true,
Expand Down Expand Up @@ -99,6 +128,10 @@ describe("terminalLinkAtColumn", () => {
expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes");
expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes");
expect(terminalLinkAtColumn(row, 0)).toBeNull();
expect(terminalLinkAtPositionWithRange([row], 0, 8)?.range).toEqual({
start: { x: 2, y: 0 },
end: { x: cells.length - 1, y: 0 },
});
});

it("uses shared path matching and reconstructs soft-wrapped links", () => {
Expand All @@ -119,6 +152,13 @@ describe("terminalLinkAtColumn", () => {
expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference");
expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file");
expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts");
expect(terminalLinkAtPositionWithRange(rows, 1, 4)).toEqual({
text: "https://example.com/reference",
range: {
start: { x: 0, y: 0 },
end: { x: 12, y: 1 },
},
});
});

it("refuses links truncated at the viewport edges instead of mis-resolving", () => {
Expand Down Expand Up @@ -245,6 +285,13 @@ describe("application mouse reporting", () => {
it("maps browser buttons to Ghostty's button enum", () => {
expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]);
});

it("only shows link hover during mouse tracking when the link modifier is held", () => {
expect(shouldShowTerminalLinkHover(false, false)).toBe(true);
expect(shouldShowTerminalLinkHover(false, true)).toBe(true);
expect(shouldShowTerminalLinkHover(true, false)).toBe(false);
expect(shouldShowTerminalLinkHover(true, true)).toBe(true);
});
});

describe("terminal font resolution", () => {
Expand Down
Loading
Loading