| ... | @@ -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"; |
| 15 | import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; | 16 | import 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 | } |
| 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 | function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { | 231 | function 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 | |
| | 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 | 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 | } |
| 340 | | 405 | |
| | 406 | export function shouldShowTerminalLinkHover( |
| | 407 | mouseTracking: boolean, |
| | 408 | linkModifierActive: boolean, |
| | 409 | ): boolean { |
| | 410 | return !mouseTracking || linkModifierActive; |
| | 411 | } |
| | 412 | |
| 341 | export function ghosttyMouseButton(button: number): number | null { | 413 | export 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 | } |
| 816 | | 891 | |
| 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 cannot | 895 | // beforeKey runs side effects (keybindings, navigation sends), so it cannot |
| 820 | // be consulted again on keyup, and Kitty report-event-types sessions would | 896 | // 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 | }; |
| 867 | | 943 | |
| 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 { |
| 887 | | 964 | |
| 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 (for | 969 | // Suppressions survive blur deliberately: a shortcut that moves focus (for |
| 891 | // example terminal-toggle) must still swallow its own keyup if focus comes | 970 | // example terminal-toggle) must still swallow its own keyup if focus comes |
| 892 | // back before release. Stale entries are harmless — an encoding keydown | 971 | // 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 | } |
| 1098 | | 1185 | |
| 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 | } |
| 1105 | | 1232 | |
| 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 !== undefined | 1531 | ...(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 | } |
| 1468 | | 1606 | |
| 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 | } |
| 1476 | | 1653 | |
| 1477 | private sendMouse( | 1654 | private sendMouse( |