Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 12 additions & 0 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
terminalScrollbarOffsetAtPointer,
terminalLinkAtColumn,
terminalLinkAtPosition,
terminalLinkAtPositionWithRange,
terminalContentOriginY,
terminalFontFamily,
fittedTerminalFontSize,
Expand Down Expand Up @@ -99,6 +100,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 +124,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
119 changes: 110 additions & 9 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
measureGhosttyCell,
renderGhosttySnapshot,
terminalGridSize,
type GhosttyCellRange,
type GhosttyCellMetrics,
} from "./renderer";
import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";
Expand Down Expand Up @@ -223,6 +224,27 @@ export function terminalLinkAtPosition(
rowIndex: number,
column: number,
): string | null {
return terminalLinkAtPositionWithRange(rows, rowIndex, column)?.text ?? null;
}

export interface TerminalLinkWithRange {
readonly text: string;
readonly range: GhosttyCellRange;
}

function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number {
for (let column = 0; column < row.cells.length; column += 1) {
const nextOffset = terminalColumnOffset(row, column + 1);
if (offset < nextOffset) return column;
}
return Math.max(0, row.cells.length - 1);
}

export function terminalLinkAtPositionWithRange(
rows: GhosttySnapshot["rowData"],
rowIndex: number,
column: number,
): TerminalLinkWithRange | null {
const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => {
const row = rows[index];
if (!row) return null;
Expand Down Expand Up @@ -251,7 +273,28 @@ export function terminalLinkAtPosition(
if (offset >= match.start && offset < match.end) {
// A truncated tail must not activate as a complete link.
if (match.end === wrappedLine.text.length && continuesBelowViewport) return null;
return match.text;
const startSegment = wrappedLine.segments.find(
(value) => match.start >= value.startIndex && match.start < value.endIndex,
);
const endSegment = wrappedLine.segments.find(
(value) => match.end - 1 >= value.startIndex && match.end - 1 < value.endIndex,
);
const startRow = startSegment ? rows[startSegment.bufferLineNumber - 1] : undefined;
const endRow = endSegment ? rows[endSegment.bufferLineNumber - 1] : undefined;
if (!startSegment || !endSegment || !startRow || !endRow) return null;
return {
text: match.text,
range: {
start: {
x: terminalColumnAtOffset(startRow, match.start - startSegment.startIndex),
y: startSegment.bufferLineNumber - 1,
},
end: {
x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex),
y: endSegment.bufferLineNumber - 1,
},
},
};
}
}
return null;
Expand Down Expand Up @@ -444,6 +487,8 @@ export class GhosttyTerminalSurface {
private mouseReportingPointerId: number | null = null;
private mouseReportingButton: number | null = null;
private linkActivationPointerId: number | null = null;
private hoveredLink: TerminalLinkWithRange | null = null;
private hoverPointer: { x: number; y: number } | null = null;
private selectionClickSequence: TerminalSelectionClickSequence | null = null;
private selectionMoved = false;
private composing = false;
Expand Down Expand Up @@ -1097,10 +1142,33 @@ export class GhosttyTerminalSurface {
}

private updateHoverCursor(event: PointerEvent): void {
const overLink =
Comment thread
StiensWout marked this conversation as resolved.
isTerminalLinkPointerGesture(event) && this.linkAt(event.clientX, event.clientY) !== null;
const cursor = overLink ? "pointer" : "";
if (this.canvas.style.cursor !== cursor) this.canvas.style.cursor = cursor;
this.hoverPointer = { x: event.clientX, y: event.clientY };
this.refreshHoveredLink();
}

private readonly onPointerLeave = () => {
this.hoverPointer = null;
this.setHoveredLink(null);
};

private refreshHoveredLink(): void {
const pointer = this.hoverPointer;
this.setHoveredLink(pointer ? this.linkAt(pointer.x, pointer.y) : null);
}

private setHoveredLink(link: TerminalLinkWithRange | null): void {
const previous = this.hoveredLink;
const unchanged =
previous?.text === link?.text &&
previous?.range.start.x === link?.range.start.x &&
previous?.range.start.y === link?.range.start.y &&
previous?.range.end.x === link?.range.end.x &&
previous?.range.end.y === link?.range.end.y;
this.canvas.style.cursor = link ? "pointer" : "";
if (unchanged) return;
this.hoveredLink = link;
this.forceFullRender = true;
this.requestRender();
}

private readonly onPointerUp = (event: PointerEvent) => {
Expand All @@ -1114,7 +1182,7 @@ export class GhosttyTerminalSurface {
}
if (event.type !== "pointercancel") {
const link = this.linkAt(event.clientX, event.clientY);
if (link) this.options.onLinkActivate(link, event);
if (link) this.options.onLinkActivate(link.text, event);
}
return;
}
Expand Down Expand Up @@ -1253,6 +1321,7 @@ export class GhosttyTerminalSurface {
this.input.addEventListener("compositionend", this.onCompositionEnd);
this.canvas.addEventListener("pointerdown", this.onPointerDown);
this.canvas.addEventListener("pointermove", this.onPointerMove);
this.canvas.addEventListener("pointerleave", this.onPointerLeave);
this.canvas.addEventListener("pointerup", this.onPointerUp);
this.canvas.addEventListener("pointercancel", this.onPointerUp);
this.canvas.addEventListener("wheel", this.onWheel, { passive: false });
Expand All @@ -1276,6 +1345,7 @@ export class GhosttyTerminalSurface {
this.input.removeEventListener("compositionend", this.onCompositionEnd);
this.canvas.removeEventListener("pointerdown", this.onPointerDown);
this.canvas.removeEventListener("pointermove", this.onPointerMove);
this.canvas.removeEventListener("pointerleave", this.onPointerLeave);
this.canvas.removeEventListener("pointerup", this.onPointerUp);
this.canvas.removeEventListener("pointercancel", this.onPointerUp);
this.canvas.removeEventListener("wheel", this.onWheel);
Expand Down Expand Up @@ -1358,6 +1428,7 @@ export class GhosttyTerminalSurface {
this.frame = 0;
}
this.snapshot = this.core.snapshot();
Comment thread
StiensWout marked this conversation as resolved.
this.refreshHoveredLink();
Comment thread
StiensWout marked this conversation as resolved.
Outdated
// A cursor that is not blinking right now must be drawn, never caught in an
// off phase left behind by a blink that has since been turned off.
if (!this.blinkEnabled()) this.cursorOn = true;
Expand Down Expand Up @@ -1390,6 +1461,7 @@ export class GhosttyTerminalSurface {
cursorOn: this.cursorOn,
previousCursorY: this.renderedCursorY,
focused: this.focused,
hoveredLinkRange: this.hoveredLink?.range ?? null,
...(this.theme.selectionBackground !== undefined
? { selectionBackground: this.theme.selectionBackground }
: {}),
Expand Down Expand Up @@ -1466,12 +1538,41 @@ export class GhosttyTerminalSurface {
};
}

private linkAt(clientX: number, clientY: number): string | null {
private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null {
Comment thread
StiensWout marked this conversation as resolved.
if (!this.snapshot) return null;
const cell = this.cellAt(clientX, clientY);
const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y);
if (explicitHyperlink) return explicitHyperlink;
return terminalLinkAtPosition(this.snapshot.rowData, cell.y, cell.x);
if (explicitHyperlink) {
const start = { ...cell };
const end = { ...cell };
while (true) {
const previous =
start.x > 0
? { x: start.x - 1, y: start.y }
: start.y > 0 && this.snapshot.rowData[start.y]?.isWrapContinuation
? { x: this.cols - 1, y: start.y - 1 }
: null;
if (!previous || this.core.hyperlinkAt(previous.x, previous.y) !== explicitHyperlink) break;
start.x = previous.x;
start.y = previous.y;
}
while (true) {
const next =
end.x + 1 < this.cols
? { x: end.x + 1, y: end.y }
: end.y + 1 < this.rows && this.snapshot.rowData[end.y]?.wrapsToNext
? { x: 0, y: end.y + 1 }
: null;
if (!next || this.core.hyperlinkAt(next.x, next.y) !== explicitHyperlink) break;
end.x = next.x;
end.y = next.y;
}
return {
text: explicitHyperlink,
range: { start, end },
};
}
return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x);
}

private sendMouse(
Expand Down
Loading