| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | measureGhosttyCell, |
| 11 | 11 | renderGhosttySnapshot, |
| 12 | 12 | terminalGridSize, |
| 13 | type GhosttyCellRange, |
| 13 | 14 | type GhosttyCellMetrics, |
| 14 | 15 | } from "./renderer"; |
| 15 | 16 | import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; |
| ... | ... | @@ -205,6 +206,28 @@ export function terminalScrollbarOffsetAtPointer( |
| 205 | 206 | return Math.round((thumbTop / travel) * geometry.maxOffset); |
| 206 | 207 | } |
| 207 | 208 | |
| 209 | export 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 | |
| 208 | 231 | function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { |
| 209 | 232 | const text = row.cells.map((cell) => cell.text || " ").join(""); |
| 210 | 233 | return trimRight ? text.trimEnd() : text; |
| ... | ... | @@ -223,6 +246,27 @@ export function terminalLinkAtPosition( |
| 223 | 246 | rowIndex: number, |
| 224 | 247 | column: number, |
| 225 | 248 | ): string | null { |
| 249 | return terminalLinkAtPositionWithRange(rows, rowIndex, column)?.text ?? null; |
| 250 | } |
| 251 | |
| 252 | export interface TerminalLinkWithRange { |
| 253 | readonly text: string; |
| 254 | readonly range: GhosttyCellRange; |
| 255 | } |
| 256 | |
| 257 | function 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 | |
| 265 | export function terminalLinkAtPositionWithRange( |
| 266 | rows: GhosttySnapshot["rowData"], |
| 267 | rowIndex: number, |
| 268 | column: number, |
| 269 | ): TerminalLinkWithRange | null { |
| 226 | 270 | const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => { |
| 227 | 271 | const row = rows[index]; |
| 228 | 272 | if (!row) return null; |
| ... | ... | @@ -251,7 +295,28 @@ export function terminalLinkAtPosition( |
| 251 | 295 | if (offset >= match.start && offset < match.end) { |
| 252 | 296 | // A truncated tail must not activate as a complete link. |
| 253 | 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 | 322 | return null; |
| ... | ... | @@ -338,6 +403,13 @@ export function isTerminalLinkPointerGesture( |
| 338 | 403 | : event.ctrlKey && !event.metaKey; |
| 339 | 404 | } |
| 340 | 405 | |
| 406 | export function shouldShowTerminalLinkHover( |
| 407 | mouseTracking: boolean, |
| 408 | linkModifierActive: boolean, |
| 409 | ): boolean { |
| 410 | return !mouseTracking || linkModifierActive; |
| 411 | } |
| 412 | |
| 341 | 413 | export function ghosttyMouseButton(button: number): number | null { |
| 342 | 414 | switch (button) { |
| 343 | 415 | case 0: |
| ... | ... | @@ -444,6 +516,9 @@ export class GhosttyTerminalSurface { |
| 444 | 516 | private mouseReportingPointerId: number | null = null; |
| 445 | 517 | private mouseReportingButton: number | null = null; |
| 446 | 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 | 522 | private selectionClickSequence: TerminalSelectionClickSequence | null = null; |
| 448 | 523 | private selectionMoved = false; |
| 449 | 524 | private composing = false; |
| ... | ... | @@ -815,6 +890,7 @@ export class GhosttyTerminalSurface { |
| 815 | 890 | } |
| 816 | 891 | |
| 817 | 892 | private readonly onKeyDown = (event: KeyboardEvent) => { |
| 893 | this.updateLinkModifier(event); |
| 818 | 894 | // Presses handled outside the terminal must also swallow their release: |
| 819 | 895 | // beforeKey runs side effects (keybindings, navigation sends), so it cannot |
| 820 | 896 | // be consulted again on keyup, and Kitty report-event-types sessions would |
| ... | ... | @@ -866,6 +942,7 @@ export class GhosttyTerminalSurface { |
| 866 | 942 | }; |
| 867 | 943 | |
| 868 | 944 | private readonly onKeyUp = (event: KeyboardEvent) => { |
| 945 | this.updateLinkModifier(event); |
| 869 | 946 | if (this.suppressedKeyCodes.delete(event.code)) return; |
| 870 | 947 | if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) { |
| 871 | 948 | return; |
| ... | ... | @@ -887,6 +964,8 @@ export class GhosttyTerminalSurface { |
| 887 | 964 | |
| 888 | 965 | private readonly onBlur = () => { |
| 889 | 966 | this.focused = false; |
| 967 | this.linkModifierActive = false; |
| 968 | this.refreshHoveredLink(); |
| 890 | 969 | // Suppressions survive blur deliberately: a shortcut that moves focus (for |
| 891 | 970 | // example terminal-toggle) must still swallow its own keyup if focus comes |
| 892 | 971 | // back before release. Stale entries are harmless — an encoding keydown |
| ... | ... | @@ -968,6 +1047,7 @@ export class GhosttyTerminalSurface { |
| 968 | 1047 | if (button === null) return; |
| 969 | 1048 | event.preventDefault(); |
| 970 | 1049 | event.stopPropagation(); |
| 1050 | this.clearHoveredLink("default"); |
| 971 | 1051 | this.mouseReportingPointerId = event.pointerId; |
| 972 | 1052 | this.mouseReportingButton = button; |
| 973 | 1053 | this.sendMouse("press", button, event); |
| ... | ... | @@ -982,6 +1062,7 @@ export class GhosttyTerminalSurface { |
| 982 | 1062 | this.canvas.setPointerCapture(event.pointerId); |
| 983 | 1063 | return; |
| 984 | 1064 | } |
| 1065 | this.clearHoveredLink(); |
| 985 | 1066 | const cell = this.cellAt(event.clientX, event.clientY); |
| 986 | 1067 | this.selectionMoved = false; |
| 987 | 1068 | this.selectionClickSequence = advanceTerminalSelectionClickSequence( |
| ... | ... | @@ -1029,6 +1110,11 @@ export class GhosttyTerminalSurface { |
| 1029 | 1110 | shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event) |
| 1030 | 1111 | ) { |
| 1031 | 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 | 1118 | this.canvas.style.cursor = "default"; |
| 1033 | 1119 | this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); |
| 1034 | 1120 | return; |
| ... | ... | @@ -1037,6 +1123,7 @@ export class GhosttyTerminalSurface { |
| 1037 | 1123 | this.updateHoverCursor(event); |
| 1038 | 1124 | return; |
| 1039 | 1125 | } |
| 1126 | this.clearHoveredLink(); |
| 1040 | 1127 | this.selectionPointer = { x: event.clientX, y: event.clientY }; |
| 1041 | 1128 | const bounds = this.canvas.getBoundingClientRect(); |
| 1042 | 1129 | this.setSelectionAutoscroll( |
| ... | ... | @@ -1097,10 +1184,50 @@ export class GhosttyTerminalSurface { |
| 1097 | 1184 | } |
| 1098 | 1185 | |
| 1099 | 1186 | private updateHoverCursor(event: PointerEvent): void { |
| 1100 | | const overLink = |
| 1101 | | isTerminalLinkPointerGesture(event) && this.linkAt(event.clientX, event.clientY) !== null; |
| 1102 | | const cursor = overLink ? "pointer" : ""; |
| 1103 | | if (this.canvas.style.cursor !== cursor) this.canvas.style.cursor = cursor; |
| 1187 | this.hoverPointer = { x: event.clientX, y: event.clientY }; |
| 1188 | this.linkModifierActive = isTerminalLinkPointerGesture(event); |
| 1189 | this.refreshHoveredLink(); |
| 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 | } |
| 1105 | 1232 | |
| 1106 | 1233 | private readonly onPointerUp = (event: PointerEvent) => { |
| ... | ... | @@ -1114,7 +1241,7 @@ export class GhosttyTerminalSurface { |
| 1114 | 1241 | } |
| 1115 | 1242 | if (event.type !== "pointercancel") { |
| 1116 | 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 | 1246 | return; |
| 1120 | 1247 | } |
| ... | ... | @@ -1127,6 +1254,13 @@ export class GhosttyTerminalSurface { |
| 1127 | 1254 | if (this.canvas.hasPointerCapture(event.pointerId)) { |
| 1128 | 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 | 1264 | return; |
| 1131 | 1265 | } |
| 1132 | 1266 | if (this.canvas.hasPointerCapture(event.pointerId)) { |
| ... | ... | @@ -1253,6 +1387,7 @@ export class GhosttyTerminalSurface { |
| 1253 | 1387 | this.input.addEventListener("compositionend", this.onCompositionEnd); |
| 1254 | 1388 | this.canvas.addEventListener("pointerdown", this.onPointerDown); |
| 1255 | 1389 | this.canvas.addEventListener("pointermove", this.onPointerMove); |
| 1390 | this.canvas.addEventListener("pointerleave", this.onPointerLeave); |
| 1256 | 1391 | this.canvas.addEventListener("pointerup", this.onPointerUp); |
| 1257 | 1392 | this.canvas.addEventListener("pointercancel", this.onPointerUp); |
| 1258 | 1393 | this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); |
| ... | ... | @@ -1276,6 +1411,7 @@ export class GhosttyTerminalSurface { |
| 1276 | 1411 | this.input.removeEventListener("compositionend", this.onCompositionEnd); |
| 1277 | 1412 | this.canvas.removeEventListener("pointerdown", this.onPointerDown); |
| 1278 | 1413 | this.canvas.removeEventListener("pointermove", this.onPointerMove); |
| 1414 | this.canvas.removeEventListener("pointerleave", this.onPointerLeave); |
| 1279 | 1415 | this.canvas.removeEventListener("pointerup", this.onPointerUp); |
| 1280 | 1416 | this.canvas.removeEventListener("pointercancel", this.onPointerUp); |
| 1281 | 1417 | this.canvas.removeEventListener("wheel", this.onWheel); |
| ... | ... | @@ -1378,6 +1514,7 @@ export class GhosttyTerminalSurface { |
| 1378 | 1514 | this.originY = nextOriginY; |
| 1379 | 1515 | this.forceFullRender = true; |
| 1380 | 1516 | } |
| 1517 | this.refreshHoveredLink(); |
| 1381 | 1518 | renderGhosttySnapshot({ |
| 1382 | 1519 | context: this.context, |
| 1383 | 1520 | snapshot: this.snapshot, |
| ... | ... | @@ -1390,6 +1527,7 @@ export class GhosttyTerminalSurface { |
| 1390 | 1527 | cursorOn: this.cursorOn, |
| 1391 | 1528 | previousCursorY: this.renderedCursorY, |
| 1392 | 1529 | focused: this.focused, |
| 1530 | hoveredLinkRange: this.hoveredLink?.range ?? null, |
| 1393 | 1531 | ...(this.theme.selectionBackground !== undefined |
| 1394 | 1532 | ? { selectionBackground: this.theme.selectionBackground } |
| 1395 | 1533 | : {}), |
| ... | ... | @@ -1466,12 +1604,51 @@ export class GhosttyTerminalSurface { |
| 1466 | 1604 | }; |
| 1467 | 1605 | } |
| 1468 | 1606 | |
| 1469 | | private linkAt(clientX: number, clientY: number): string | null { |
| 1607 | private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null { |
| 1470 | 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 | 1620 | const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y); |
| 1473 | | if (explicitHyperlink) return explicitHyperlink; |
| 1474 | | return terminalLinkAtPosition(this.snapshot.rowData, cell.y, cell.x); |
| 1621 | if (explicitHyperlink) { |
| 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 | } |
| 1476 | 1653 | |
| 1477 | 1654 | private sendMouse( |