authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-05 11:20:17+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-05 11:20:17+02:00
log9d9a872bccc6ed099de85a121ab042c9e078987e
tree9d2e3af3556ca4472e5057d76326485836ed1e4d
parent9235c83eb7f16c321ad02aa4cc502fbe0be252c1
signature Signed by PGP key B5690EEEBB952194

fix(web): restore terminal link hover styles (#5382)


4 files changed, 309 insertions(+), 12 deletions(-)

apps/web/src/terminal/ghostty/renderer.test.ts+56
...@@ -71,6 +71,62 @@ describe("ghosttyTextRunEnd", () => {...@@ -71,6 +71,62 @@ describe("ghosttyTextRunEnd", () => {
71});71});
7272
73describe("renderGhosttySnapshot", () => {73describe("renderGhosttySnapshot", () => {
74 it("underlines every cell in a hovered wrapped link", () => {
75 const fillRectCalls: number[][] = [];
76 const context = {
77 canvas: { width: 200, height: 80 },
78 beginPath: () => {},
79 clip: () => {},
80 fillRect: (...args: number[]) => fillRectCalls.push(args),
81 fillText: () => {},
82 rect: () => {},
83 resetTransform: () => {},
84 restore: () => {},
85 save: () => {},
86 set fillStyle(_value: string) {},
87 set font(_value: string) {},
88 set textBaseline(_value: string) {},
89 } as unknown as CanvasRenderingContext2D;
90 const snapshot: GhosttySnapshot = {
91 cols: 4,
92 rows: 2,
93 foreground: { r: 255, g: 255, b: 255 },
94 background: { r: 0, g: 0, b: 0 },
95 cursor: { r: 255, g: 255, b: 255 },
96 cursorX: -1,
97 cursorY: -1,
98 cursorVisible: false,
99 cursorBlinking: false,
100 cursorStyle: 1,
101 dirtyRows: new Set([0, 1]),
102 rowData: [0, 1].map(() => ({
103 cells: [cell("a"), cell("b"), cell("c"), cell("d")],
104 text: "abcd",
105 isWrapContinuation: false,
106 wrapsToNext: false,
107 })),
108 };
109
110 renderGhosttySnapshot({
111 context,
112 snapshot,
113 metrics: { width: 10, height: 20, baseline: 15 },
114 fontSize: 12,
115 fontFamily: "monospace",
116 padding: 4,
117 forceFull: false,
118 cursorOn: false,
119 hoveredLinkRange: { start: { x: 2, y: 0 }, end: { x: 1, y: 1 } },
120 });
121
122 expect(fillRectCalls.filter(([, , , height]) => height === 1)).toEqual([
123 [24, 22, 10, 1],
124 [34, 22, 10, 1],
125 [4, 42, 10, 1],
126 [14, 42, 10, 1],
127 ]);
128 });
129
74 it("constrains text runs and cursor glyphs to their terminal cells", () => {130 it("constrains text runs and cursor glyphs to their terminal cells", () => {
75 const fillTextCalls: unknown[][] = [];131 const fillTextCalls: unknown[][] = [];
76 const context = {132 const context = {
apps/web/src/terminal/ghostty/renderer.ts+19-2
...@@ -12,6 +12,11 @@ export interface GhosttyCellMetrics {...@@ -12,6 +12,11 @@ export interface GhosttyCellMetrics {
12 readonly baseline: number;12 readonly baseline: number;
13}13}
1414
15export interface GhosttyCellRange {
16 readonly start: { readonly x: number; readonly y: number };
17 readonly end: { readonly x: number; readonly y: number };
18}
19
15const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)";20const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)";
1621
17function cssColor(color: GhosttyColor): string {22function cssColor(color: GhosttyColor): string {
...@@ -98,6 +103,7 @@ export function renderGhosttySnapshot(options: {...@@ -98,6 +103,7 @@ export function renderGhosttySnapshot(options: {
98 readonly previousCursorY?: number | null;103 readonly previousCursorY?: number | null;
99 readonly focused?: boolean;104 readonly focused?: boolean;
100 readonly selectionBackground?: string;105 readonly selectionBackground?: string;
106 readonly hoveredLinkRange?: GhosttyCellRange | null;
101 /** Vertical origin of row 0; defaults to the horizontal padding. */107 /** Vertical origin of row 0; defaults to the horizontal padding. */
102 readonly originY?: number;108 readonly originY?: number;
103}): void {109}): void {
...@@ -114,6 +120,7 @@ export function renderGhosttySnapshot(options: {...@@ -114,6 +120,7 @@ export function renderGhosttySnapshot(options: {
114 } = options;120 } = options;
115 const focused = options.focused ?? true;121 const focused = options.focused ?? true;
116 const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND;122 const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND;
123 const hoveredLinkRange = options.hoveredLinkRange ?? null;
117 const originY = options.originY ?? padding;124 const originY = options.originY ?? padding;
118 const rowsToDraw = forceFull125 const rowsToDraw = forceFull
119 ? Array.from({ length: snapshot.rows }, (_, index) => index)126 ? Array.from({ length: snapshot.rows }, (_, index) => index)
...@@ -216,10 +223,20 @@ export function renderGhosttySnapshot(options: {...@@ -216,10 +223,20 @@ export function renderGhosttySnapshot(options: {
216223
217 for (let column = 0; column < row.cells.length; column += 1) {224 for (let column = 0; column < row.cells.length; column += 1) {
218 const cell = row.cells[column];225 const cell = row.cells[column];
219 if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline)) continue;226 const hoveredLink =
227 hoveredLinkRange !== null &&
228 rowIndex >= hoveredLinkRange.start.y &&
229 rowIndex <= hoveredLinkRange.end.y &&
230 (rowIndex > hoveredLinkRange.start.y || column >= hoveredLinkRange.start.x) &&
231 (rowIndex < hoveredLinkRange.end.y || column <= hoveredLinkRange.end.x);
232 if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline && !hoveredLink)) {
233 continue;
234 }
220 context.fillStyle = cssColor(cell.foreground);235 context.fillStyle = cssColor(cell.foreground);
221 const left = padding + column * metrics.width;236 const left = padding + column * metrics.width;
222 if (cell.underline) context.fillRect(left, top + metrics.height - 2, metrics.width, 1);237 if (cell.underline || hoveredLink) {
238 context.fillRect(left, top + metrics.height - 2, metrics.width, 1);
239 }
223 if (cell.strikethrough) {240 if (cell.strikethrough) {
224 context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1);241 context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1);
225 }242 }
apps/web/src/terminal/ghostty/surface.test.ts+47
...@@ -13,10 +13,13 @@ import {...@@ -13,10 +13,13 @@ import {
13 isTerminalPasteShortcut,13 isTerminalPasteShortcut,
14 shouldBlinkTerminalCursor,14 shouldBlinkTerminalCursor,
15 shouldReportTerminalMouse,15 shouldReportTerminalMouse,
16 shouldShowTerminalLinkHover,
17 terminalGridCellAt,
16 terminalScrollbarGeometry,18 terminalScrollbarGeometry,
17 terminalScrollbarOffsetAtPointer,19 terminalScrollbarOffsetAtPointer,
18 terminalLinkAtColumn,20 terminalLinkAtColumn,
19 terminalLinkAtPosition,21 terminalLinkAtPosition,
22 terminalLinkAtPositionWithRange,
20 terminalContentOriginY,23 terminalContentOriginY,
21 terminalFontFamily,24 terminalFontFamily,
22 fittedTerminalFontSize,25 fittedTerminalFontSize,
...@@ -56,6 +59,32 @@ describe("isTerminalAltGraphText", () => {...@@ -56,6 +59,32 @@ describe("isTerminalAltGraphText", () => {
56 });59 });
57});60});
5861
62describe("terminalGridCellAt", () => {
63 const options = {
64 bounds: { left: 100, top: 200 },
65 cols: 3,
66 rows: 2,
67 metrics: { width: 10, height: 20 },
68 padding: 4,
69 originY: 24,
70 };
71
72 it("maps points inside the rendered grid without clamping its padding", () => {
73 expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 224 })).toEqual({
74 x: 0,
75 y: 0,
76 });
77 expect(terminalGridCellAt({ ...options, clientX: 133, clientY: 263 })).toEqual({
78 x: 2,
79 y: 1,
80 });
81 expect(terminalGridCellAt({ ...options, clientX: 103, clientY: 224 })).toBeNull();
82 expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 223 })).toBeNull();
83 expect(terminalGridCellAt({ ...options, clientX: 134, clientY: 224 })).toBeNull();
84 expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 264 })).toBeNull();
85 });
86});
87
59describe("shouldBlinkTerminalCursor", () => {88describe("shouldBlinkTerminalCursor", () => {
60 const blinking = {89 const blinking = {
61 focused: true,90 focused: true,
...@@ -99,6 +128,10 @@ describe("terminalLinkAtColumn", () => {...@@ -99,6 +128,10 @@ describe("terminalLinkAtColumn", () => {
99 expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes");128 expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes");
100 expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes");129 expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes");
101 expect(terminalLinkAtColumn(row, 0)).toBeNull();130 expect(terminalLinkAtColumn(row, 0)).toBeNull();
131 expect(terminalLinkAtPositionWithRange([row], 0, 8)?.range).toEqual({
132 start: { x: 2, y: 0 },
133 end: { x: cells.length - 1, y: 0 },
134 });
102 });135 });
103136
104 it("uses shared path matching and reconstructs soft-wrapped links", () => {137 it("uses shared path matching and reconstructs soft-wrapped links", () => {
...@@ -119,6 +152,13 @@ describe("terminalLinkAtColumn", () => {...@@ -119,6 +152,13 @@ describe("terminalLinkAtColumn", () => {
119 expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference");152 expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference");
120 expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file");153 expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file");
121 expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts");154 expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts");
155 expect(terminalLinkAtPositionWithRange(rows, 1, 4)).toEqual({
156 text: "https://example.com/reference",
157 range: {
158 start: { x: 0, y: 0 },
159 end: { x: 12, y: 1 },
160 },
161 });
122 });162 });
123163
124 it("refuses links truncated at the viewport edges instead of mis-resolving", () => {164 it("refuses links truncated at the viewport edges instead of mis-resolving", () => {
...@@ -245,6 +285,13 @@ describe("application mouse reporting", () => {...@@ -245,6 +285,13 @@ describe("application mouse reporting", () => {
245 it("maps browser buttons to Ghostty's button enum", () => {285 it("maps browser buttons to Ghostty's button enum", () => {
246 expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]);286 expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]);
247 });287 });
288
289 it("only shows link hover during mouse tracking when the link modifier is held", () => {
290 expect(shouldShowTerminalLinkHover(false, false)).toBe(true);
291 expect(shouldShowTerminalLinkHover(false, true)).toBe(true);
292 expect(shouldShowTerminalLinkHover(true, false)).toBe(false);
293 expect(shouldShowTerminalLinkHover(true, true)).toBe(true);
294 });
248});295});
249296
250describe("terminal font resolution", () => {297describe("terminal font resolution", () => {
apps/web/src/terminal/ghostty/surface.ts+187-10
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 measureGhosttyCell,10 measureGhosttyCell,
11 renderGhosttySnapshot,11 renderGhosttySnapshot,
12 terminalGridSize,12 terminalGridSize,
13 type GhosttyCellRange,
13 type GhosttyCellMetrics,14 type GhosttyCellMetrics,
14} from "./renderer";15} from "./renderer";
15import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";16import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";
...@@ -205,6 +206,28 @@ export function terminalScrollbarOffsetAtPointer(...@@ -205,6 +206,28 @@ export function terminalScrollbarOffsetAtPointer(
205 return Math.round((thumbTop / travel) * geometry.maxOffset);206 return Math.round((thumbTop / travel) * geometry.maxOffset);
206}207}
207208
209export function terminalGridCellAt(options: {
210 bounds: { left: number; top: number };
211 clientX: number;
212 clientY: number;
213 cols: number;
214 rows: number;
215 metrics: Pick<GhosttyCellMetrics, "width" | "height">;
216 padding: number;
217 originY: number;
218}): { x: number; y: number } | null {
219 const { bounds, clientX, clientY, cols, rows, metrics, padding, originY } = options;
220 const gridX = clientX - bounds.left - padding;
221 const gridY = clientY - bounds.top - originY;
222 if (gridX < 0 || gridY < 0 || gridX >= cols * metrics.width || gridY >= rows * metrics.height) {
223 return null;
224 }
225 return {
226 x: Math.floor(gridX / metrics.width),
227 y: Math.floor(gridY / metrics.height),
228 };
229}
230
208function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string {231function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string {
209 const text = row.cells.map((cell) => cell.text || " ").join("");232 const text = row.cells.map((cell) => cell.text || " ").join("");
210 return trimRight ? text.trimEnd() : text;233 return trimRight ? text.trimEnd() : text;
...@@ -223,6 +246,27 @@ export function terminalLinkAtPosition(...@@ -223,6 +246,27 @@ export function terminalLinkAtPosition(
223 rowIndex: number,246 rowIndex: number,
224 column: number,247 column: number,
225): string | null {248): string | null {
249 return terminalLinkAtPositionWithRange(rows, rowIndex, column)?.text ?? null;
250}
251
252export interface TerminalLinkWithRange {
253 readonly text: string;
254 readonly range: GhosttyCellRange;
255}
256
257function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number {
258 for (let column = 0; column < row.cells.length; column += 1) {
259 const nextOffset = terminalColumnOffset(row, column + 1);
260 if (offset < nextOffset) return column;
261 }
262 return Math.max(0, row.cells.length - 1);
263}
264
265export function terminalLinkAtPositionWithRange(
266 rows: GhosttySnapshot["rowData"],
267 rowIndex: number,
268 column: number,
269): TerminalLinkWithRange | null {
226 const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => {270 const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => {
227 const row = rows[index];271 const row = rows[index];
228 if (!row) return null;272 if (!row) return null;
...@@ -251,7 +295,28 @@ export function terminalLinkAtPosition(...@@ -251,7 +295,28 @@ export function terminalLinkAtPosition(
251 if (offset >= match.start && offset < match.end) {295 if (offset >= match.start && offset < match.end) {
252 // A truncated tail must not activate as a complete link.296 // A truncated tail must not activate as a complete link.
253 if (match.end === wrappedLine.text.length && continuesBelowViewport) return null;297 if (match.end === wrappedLine.text.length && continuesBelowViewport) return null;
254 return match.text;298 const startSegment = wrappedLine.segments.find(
299 (value) => match.start >= value.startIndex && match.start < value.endIndex,
300 );
301 const endSegment = wrappedLine.segments.find(
302 (value) => match.end - 1 >= value.startIndex && match.end - 1 < value.endIndex,
303 );
304 const startRow = startSegment ? rows[startSegment.bufferLineNumber - 1] : undefined;
305 const endRow = endSegment ? rows[endSegment.bufferLineNumber - 1] : undefined;
306 if (!startSegment || !endSegment || !startRow || !endRow) return null;
307 return {
308 text: match.text,
309 range: {
310 start: {
311 x: terminalColumnAtOffset(startRow, match.start - startSegment.startIndex),
312 y: startSegment.bufferLineNumber - 1,
313 },
314 end: {
315 x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex),
316 y: endSegment.bufferLineNumber - 1,
317 },
318 },
319 };
255 }320 }
256 }321 }
257 return null;322 return null;
...@@ -338,6 +403,13 @@ export function isTerminalLinkPointerGesture(...@@ -338,6 +403,13 @@ export function isTerminalLinkPointerGesture(
338 : event.ctrlKey && !event.metaKey;403 : event.ctrlKey && !event.metaKey;
339}404}
340405
406export function shouldShowTerminalLinkHover(
407 mouseTracking: boolean,
408 linkModifierActive: boolean,
409): boolean {
410 return !mouseTracking || linkModifierActive;
411}
412
341export function ghosttyMouseButton(button: number): number | null {413export function ghosttyMouseButton(button: number): number | null {
342 switch (button) {414 switch (button) {
343 case 0:415 case 0:
...@@ -444,6 +516,9 @@ export class GhosttyTerminalSurface {...@@ -444,6 +516,9 @@ export class GhosttyTerminalSurface {
444 private mouseReportingPointerId: number | null = null;516 private mouseReportingPointerId: number | null = null;
445 private mouseReportingButton: number | null = null;517 private mouseReportingButton: number | null = null;
446 private linkActivationPointerId: number | null = null;518 private linkActivationPointerId: number | null = null;
519 private hoveredLink: TerminalLinkWithRange | null = null;
520 private hoverPointer: { x: number; y: number } | null = null;
521 private linkModifierActive = false;
447 private selectionClickSequence: TerminalSelectionClickSequence | null = null;522 private selectionClickSequence: TerminalSelectionClickSequence | null = null;
448 private selectionMoved = false;523 private selectionMoved = false;
449 private composing = false;524 private composing = false;
...@@ -815,6 +890,7 @@ export class GhosttyTerminalSurface {...@@ -815,6 +890,7 @@ export class GhosttyTerminalSurface {
815 }890 }
816891
817 private readonly onKeyDown = (event: KeyboardEvent) => {892 private readonly onKeyDown = (event: KeyboardEvent) => {
893 this.updateLinkModifier(event);
818 // Presses handled outside the terminal must also swallow their release:894 // Presses handled outside the terminal must also swallow their release:
819 // beforeKey runs side effects (keybindings, navigation sends), so it cannot895 // beforeKey runs side effects (keybindings, navigation sends), so it cannot
820 // be consulted again on keyup, and Kitty report-event-types sessions would896 // be consulted again on keyup, and Kitty report-event-types sessions would
...@@ -866,6 +942,7 @@ export class GhosttyTerminalSurface {...@@ -866,6 +942,7 @@ export class GhosttyTerminalSurface {
866 };942 };
867943
868 private readonly onKeyUp = (event: KeyboardEvent) => {944 private readonly onKeyUp = (event: KeyboardEvent) => {
945 this.updateLinkModifier(event);
869 if (this.suppressedKeyCodes.delete(event.code)) return;946 if (this.suppressedKeyCodes.delete(event.code)) return;
870 if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) {947 if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) {
871 return;948 return;
...@@ -887,6 +964,8 @@ export class GhosttyTerminalSurface {...@@ -887,6 +964,8 @@ export class GhosttyTerminalSurface {
887964
888 private readonly onBlur = () => {965 private readonly onBlur = () => {
889 this.focused = false;966 this.focused = false;
967 this.linkModifierActive = false;
968 this.refreshHoveredLink();
890 // Suppressions survive blur deliberately: a shortcut that moves focus (for969 // Suppressions survive blur deliberately: a shortcut that moves focus (for
891 // example terminal-toggle) must still swallow its own keyup if focus comes970 // example terminal-toggle) must still swallow its own keyup if focus comes
892 // back before release. Stale entries are harmless — an encoding keydown971 // back before release. Stale entries are harmless — an encoding keydown
...@@ -968,6 +1047,7 @@ export class GhosttyTerminalSurface {...@@ -968,6 +1047,7 @@ export class GhosttyTerminalSurface {
968 if (button === null) return;1047 if (button === null) return;
969 event.preventDefault();1048 event.preventDefault();
970 event.stopPropagation();1049 event.stopPropagation();
1050 this.clearHoveredLink("default");
971 this.mouseReportingPointerId = event.pointerId;1051 this.mouseReportingPointerId = event.pointerId;
972 this.mouseReportingButton = button;1052 this.mouseReportingButton = button;
973 this.sendMouse("press", button, event);1053 this.sendMouse("press", button, event);
...@@ -982,6 +1062,7 @@ export class GhosttyTerminalSurface {...@@ -982,6 +1062,7 @@ export class GhosttyTerminalSurface {
982 this.canvas.setPointerCapture(event.pointerId);1062 this.canvas.setPointerCapture(event.pointerId);
983 return;1063 return;
984 }1064 }
1065 this.clearHoveredLink();
985 const cell = this.cellAt(event.clientX, event.clientY);1066 const cell = this.cellAt(event.clientX, event.clientY);
986 this.selectionMoved = false;1067 this.selectionMoved = false;
987 this.selectionClickSequence = advanceTerminalSelectionClickSequence(1068 this.selectionClickSequence = advanceTerminalSelectionClickSequence(
...@@ -1029,6 +1110,11 @@ export class GhosttyTerminalSurface {...@@ -1029,6 +1110,11 @@ export class GhosttyTerminalSurface {
1029 shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event)1110 shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event)
1030 ) {1111 ) {
1031 event.preventDefault();1112 event.preventDefault();
1113 this.hoverPointer = { x: event.clientX, y: event.clientY };
1114 this.linkModifierActive = isTerminalLinkPointerGesture(event);
1115 // A drag whose press was already sent to the terminal application cannot
1116 // turn into link activation midway through, so link feedback would lie.
1117 this.setHoveredLink(null);
1032 this.canvas.style.cursor = "default";1118 this.canvas.style.cursor = "default";
1033 this.sendMouse("motion", this.buttonFromButtons(event.buttons), event);1119 this.sendMouse("motion", this.buttonFromButtons(event.buttons), event);
1034 return;1120 return;
...@@ -1037,6 +1123,7 @@ export class GhosttyTerminalSurface {...@@ -1037,6 +1123,7 @@ export class GhosttyTerminalSurface {
1037 this.updateHoverCursor(event);1123 this.updateHoverCursor(event);
1038 return;1124 return;
1039 }1125 }
1126 this.clearHoveredLink();
1040 this.selectionPointer = { x: event.clientX, y: event.clientY };1127 this.selectionPointer = { x: event.clientX, y: event.clientY };
1041 const bounds = this.canvas.getBoundingClientRect();1128 const bounds = this.canvas.getBoundingClientRect();
1042 this.setSelectionAutoscroll(1129 this.setSelectionAutoscroll(
...@@ -1097,10 +1184,50 @@ export class GhosttyTerminalSurface {...@@ -1097,10 +1184,50 @@ export class GhosttyTerminalSurface {
1097 }1184 }
10981185
1099 private updateHoverCursor(event: PointerEvent): void {1186 private updateHoverCursor(event: PointerEvent): void {
1100 const overLink =1187 this.hoverPointer = { x: event.clientX, y: event.clientY };
1101 isTerminalLinkPointerGesture(event) && this.linkAt(event.clientX, event.clientY) !== null;1188 this.linkModifierActive = isTerminalLinkPointerGesture(event);
1102 const cursor = overLink ? "pointer" : "";1189 this.refreshHoveredLink();
1103 if (this.canvas.style.cursor !== cursor) this.canvas.style.cursor = cursor;1190 }
1191
1192 private updateLinkModifier(event: Pick<KeyboardEvent, "ctrlKey" | "metaKey">): void {
1193 const active = isTerminalLinkPointerGesture(event);
1194 if (active === this.linkModifierActive) return;
1195 this.linkModifierActive = active;
1196 this.refreshHoveredLink();
1197 }
1198
1199 private readonly onPointerLeave = () => {
1200 this.clearHoveredLink();
1201 };
1202
1203 private clearHoveredLink(cursor = ""): void {
1204 this.hoverPointer = null;
1205 this.setHoveredLink(null);
1206 this.canvas.style.cursor = cursor;
1207 }
1208
1209 private refreshHoveredLink(): void {
1210 const pointer = this.hoverPointer;
1211 const link =
1212 pointer && shouldShowTerminalLinkHover(this.core.isMouseTracking(), this.linkModifierActive)
1213 ? this.linkAt(pointer.x, pointer.y)
1214 : null;
1215 this.setHoveredLink(link);
1216 }
1217
1218 private setHoveredLink(link: TerminalLinkWithRange | null): void {
1219 const previous = this.hoveredLink;
1220 const unchanged =
1221 previous?.text === link?.text &&
1222 previous?.range.start.x === link?.range.start.x &&
1223 previous?.range.start.y === link?.range.start.y &&
1224 previous?.range.end.x === link?.range.end.x &&
1225 previous?.range.end.y === link?.range.end.y;
1226 this.canvas.style.cursor = link ? "pointer" : "";
1227 if (unchanged) return;
1228 this.hoveredLink = link;
1229 this.forceFullRender = true;
1230 this.requestRender();
1104 }1231 }
11051232
1106 private readonly onPointerUp = (event: PointerEvent) => {1233 private readonly onPointerUp = (event: PointerEvent) => {
...@@ -1114,7 +1241,7 @@ export class GhosttyTerminalSurface {...@@ -1114,7 +1241,7 @@ export class GhosttyTerminalSurface {
1114 }1241 }
1115 if (event.type !== "pointercancel") {1242 if (event.type !== "pointercancel") {
1116 const link = this.linkAt(event.clientX, event.clientY);1243 const link = this.linkAt(event.clientX, event.clientY);
1117 if (link) this.options.onLinkActivate(link, event);1244 if (link) this.options.onLinkActivate(link.text, event);
1118 }1245 }
1119 return;1246 return;
1120 }1247 }
...@@ -1127,6 +1254,13 @@ export class GhosttyTerminalSurface {...@@ -1127,6 +1254,13 @@ export class GhosttyTerminalSurface {
1127 if (this.canvas.hasPointerCapture(event.pointerId)) {1254 if (this.canvas.hasPointerCapture(event.pointerId)) {
1128 this.canvas.releasePointerCapture(event.pointerId);1255 this.canvas.releasePointerCapture(event.pointerId);
1129 }1256 }
1257 if (event.type === "pointercancel") {
1258 this.clearHoveredLink();
1259 } else {
1260 this.hoverPointer = { x: event.clientX, y: event.clientY };
1261 this.linkModifierActive = isTerminalLinkPointerGesture(event);
1262 this.refreshHoveredLink();
1263 }
1130 return;1264 return;
1131 }1265 }
1132 if (this.canvas.hasPointerCapture(event.pointerId)) {1266 if (this.canvas.hasPointerCapture(event.pointerId)) {
...@@ -1253,6 +1387,7 @@ export class GhosttyTerminalSurface {...@@ -1253,6 +1387,7 @@ export class GhosttyTerminalSurface {
1253 this.input.addEventListener("compositionend", this.onCompositionEnd);1387 this.input.addEventListener("compositionend", this.onCompositionEnd);
1254 this.canvas.addEventListener("pointerdown", this.onPointerDown);1388 this.canvas.addEventListener("pointerdown", this.onPointerDown);
1255 this.canvas.addEventListener("pointermove", this.onPointerMove);1389 this.canvas.addEventListener("pointermove", this.onPointerMove);
1390 this.canvas.addEventListener("pointerleave", this.onPointerLeave);
1256 this.canvas.addEventListener("pointerup", this.onPointerUp);1391 this.canvas.addEventListener("pointerup", this.onPointerUp);
1257 this.canvas.addEventListener("pointercancel", this.onPointerUp);1392 this.canvas.addEventListener("pointercancel", this.onPointerUp);
1258 this.canvas.addEventListener("wheel", this.onWheel, { passive: false });1393 this.canvas.addEventListener("wheel", this.onWheel, { passive: false });
...@@ -1276,6 +1411,7 @@ export class GhosttyTerminalSurface {...@@ -1276,6 +1411,7 @@ export class GhosttyTerminalSurface {
1276 this.input.removeEventListener("compositionend", this.onCompositionEnd);1411 this.input.removeEventListener("compositionend", this.onCompositionEnd);
1277 this.canvas.removeEventListener("pointerdown", this.onPointerDown);1412 this.canvas.removeEventListener("pointerdown", this.onPointerDown);
1278 this.canvas.removeEventListener("pointermove", this.onPointerMove);1413 this.canvas.removeEventListener("pointermove", this.onPointerMove);
1414 this.canvas.removeEventListener("pointerleave", this.onPointerLeave);
1279 this.canvas.removeEventListener("pointerup", this.onPointerUp);1415 this.canvas.removeEventListener("pointerup", this.onPointerUp);
1280 this.canvas.removeEventListener("pointercancel", this.onPointerUp);1416 this.canvas.removeEventListener("pointercancel", this.onPointerUp);
1281 this.canvas.removeEventListener("wheel", this.onWheel);1417 this.canvas.removeEventListener("wheel", this.onWheel);
...@@ -1378,6 +1514,7 @@ export class GhosttyTerminalSurface {...@@ -1378,6 +1514,7 @@ export class GhosttyTerminalSurface {
1378 this.originY = nextOriginY;1514 this.originY = nextOriginY;
1379 this.forceFullRender = true;1515 this.forceFullRender = true;
1380 }1516 }
1517 this.refreshHoveredLink();
1381 renderGhosttySnapshot({1518 renderGhosttySnapshot({
1382 context: this.context,1519 context: this.context,
1383 snapshot: this.snapshot,1520 snapshot: this.snapshot,
...@@ -1390,6 +1527,7 @@ export class GhosttyTerminalSurface {...@@ -1390,6 +1527,7 @@ export class GhosttyTerminalSurface {
1390 cursorOn: this.cursorOn,1527 cursorOn: this.cursorOn,
1391 previousCursorY: this.renderedCursorY,1528 previousCursorY: this.renderedCursorY,
1392 focused: this.focused,1529 focused: this.focused,
1530 hoveredLinkRange: this.hoveredLink?.range ?? null,
1393 ...(this.theme.selectionBackground !== undefined1531 ...(this.theme.selectionBackground !== undefined
1394 ? { selectionBackground: this.theme.selectionBackground }1532 ? { selectionBackground: this.theme.selectionBackground }
1395 : {}),1533 : {}),
...@@ -1466,12 +1604,51 @@ export class GhosttyTerminalSurface {...@@ -1466,12 +1604,51 @@ export class GhosttyTerminalSurface {
1466 };1604 };
1467 }1605 }
14681606
1469 private linkAt(clientX: number, clientY: number): string | null {1607 private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null {
1470 if (!this.snapshot) return null;1608 if (!this.snapshot) return null;
1471 const cell = this.cellAt(clientX, clientY);1609 const cell = terminalGridCellAt({
1610 bounds: this.canvas.getBoundingClientRect(),
1611 clientX,
1612 clientY,
1613 cols: this.cols,
1614 rows: this.rows,
1615 metrics: this.metrics,
1616 padding: CONTENT_PADDING,
1617 originY: this.originY,
1618 });
1619 if (!cell) return null;
1472 const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y);1620 const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y);
1473 if (explicitHyperlink) return explicitHyperlink;1621 if (explicitHyperlink) {
1474 return terminalLinkAtPosition(this.snapshot.rowData, cell.y, cell.x);1622 const start = { ...cell };
1623 const end = { ...cell };
1624 while (true) {
1625 const previous =
1626 start.x > 0
1627 ? { x: start.x - 1, y: start.y }
1628 : start.y > 0 && this.snapshot.rowData[start.y]?.isWrapContinuation
1629 ? { x: this.cols - 1, y: start.y - 1 }
1630 : null;
1631 if (!previous || this.core.hyperlinkAt(previous.x, previous.y) !== explicitHyperlink) break;
1632 start.x = previous.x;
1633 start.y = previous.y;
1634 }
1635 while (true) {
1636 const next =
1637 end.x + 1 < this.cols
1638 ? { x: end.x + 1, y: end.y }
1639 : end.y + 1 < this.rows && this.snapshot.rowData[end.y]?.wrapsToNext
1640 ? { x: 0, y: end.y + 1 }
1641 : null;
1642 if (!next || this.core.hyperlinkAt(next.x, next.y) !== explicitHyperlink) break;
1643 end.x = next.x;
1644 end.y = next.y;
1645 }
1646 return {
1647 text: explicitHyperlink,
1648 range: { start, end },
1649 };
1650 }
1651 return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x);
1475 }1652 }
14761653
1477 private sendMouse(1654 private sendMouse(