| author | |
| committer | |
| log | 772ea1473a4a8f8fb0e0a5c9ba6d7a2ef14eac6e |
| tree | d4d6aafe9a542501c1e358bbd1ad791adae4190b |
| parent | 5d14c0e9686d8bedf47dd93dd3811fd639255c7e |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 184 insertions(+), 57 deletions(-)
apps/web/src/components/ThreadTerminalDrawer.tsx+2-2| ... | ... | @@ -57,7 +57,7 @@ import { |
| 57 | 57 | } from "~/terminal/ghostty/surface"; |
| 58 | 58 | import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; |
| 59 | 59 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 60 | import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; | |
| 60 | import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; | |
| 61 | 61 | import { |
| 62 | 62 | isDiffToggleShortcut, |
| 63 | 63 | isTerminalClearShortcut, |
| ... | ... | @@ -782,7 +782,6 @@ export function TerminalViewport({ |
| 782 | 782 | } |
| 783 | 783 | |
| 784 | 784 | function handleLinkActivate(text: string, event: MouseEvent): void { |
| 785 | if (!isTerminalLinkActivation(event)) return; | |
| 786 | 785 | const latestTerminal = terminalRef.current; |
| 787 | 786 | if (!latestTerminal) return; |
| 788 | 787 | if (isTerminalUrl(text)) { |
| ... | ... | @@ -803,6 +802,7 @@ export function TerminalViewport({ |
| 803 | 802 | threadRef, |
| 804 | 803 | openPreview, |
| 805 | 804 | fallbackToBrowser, |
| 805 | forceBrowser: event.metaKey || event.ctrlKey, | |
| 806 | 806 | }).catch((error: unknown) => { |
| 807 | 807 | toastManager.add( |
| 808 | 808 | stackedThreadToast({ |
apps/web/src/components/preview/openTerminalLinkInPreview.test.ts+22| ... | ... | @@ -88,6 +88,7 @@ describe("openTerminalLinkInPreview", () => { |
| 88 | 88 | threadRef, |
| 89 | 89 | openPreview, |
| 90 | 90 | fallbackToBrowser, |
| 91 | forceBrowser: false, | |
| 91 | 92 | }), |
| 92 | 93 | ).rejects.toBe(failure); |
| 93 | 94 | expect(fallbackToBrowser).not.toHaveBeenCalled(); |
| ... | ... | @@ -105,6 +106,7 @@ describe("openTerminalLinkInPreview", () => { |
| 105 | 106 | threadRef, |
| 106 | 107 | openPreview, |
| 107 | 108 | fallbackToBrowser, |
| 109 | forceBrowser: false, | |
| 108 | 110 | }); |
| 109 | 111 | |
| 110 | 112 | expect(fallbackToBrowser).toHaveBeenCalledOnce(); |
| ... | ... | @@ -120,6 +122,7 @@ describe("openTerminalLinkInPreview", () => { |
| 120 | 122 | threadRef, |
| 121 | 123 | openPreview, |
| 122 | 124 | fallbackToBrowser, |
| 125 | forceBrowser: false, | |
| 123 | 126 | }); |
| 124 | 127 | |
| 125 | 128 | expect(openPreview).toHaveBeenCalledOnce(); |
| ... | ... | @@ -141,6 +144,7 @@ describe("openTerminalLinkInPreview", () => { |
| 141 | 144 | threadRef, |
| 142 | 145 | openPreview, |
| 143 | 146 | fallbackToBrowser: vi.fn(), |
| 147 | forceBrowser: false, | |
| 144 | 148 | }); |
| 145 | 149 | |
| 146 | 150 | await vi.waitFor(() => expect(browserDefaultsMocks.resolve).toHaveBeenCalledOnce()); |
| ... | ... | @@ -170,6 +174,7 @@ describe("openTerminalLinkInPreview", () => { |
| 170 | 174 | threadRef, |
| 171 | 175 | openPreview: async () => AsyncResult.failure(cause), |
| 172 | 176 | fallbackToBrowser, |
| 177 | forceBrowser: false, | |
| 173 | 178 | }); |
| 174 | 179 | |
| 175 | 180 | expect(fallbackToBrowser).toHaveBeenCalledOnce(); |
| ... | ... | @@ -194,9 +199,26 @@ describe("openTerminalLinkInPreview", () => { |
| 194 | 199 | threadRef, |
| 195 | 200 | openPreview: async () => AsyncResult.failure(Cause.interrupt()), |
| 196 | 201 | fallbackToBrowser, |
| 202 | forceBrowser: false, | |
| 197 | 203 | }); |
| 198 | 204 | |
| 199 | 205 | expect(reportError).not.toHaveBeenCalled(); |
| 200 | 206 | expect(fallbackToBrowser).not.toHaveBeenCalled(); |
| 201 | 207 | }); |
| 208 | ||
| 209 | it("opens in the system browser when Ctrl or Command is held", async () => { | |
| 210 | const fallbackToBrowser = vi.fn(); | |
| 211 | const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); | |
| 212 | ||
| 213 | await openTerminalLinkInPreview({ | |
| 214 | url: "https://example.com/docs", | |
| 215 | threadRef, | |
| 216 | openPreview, | |
| 217 | fallbackToBrowser, | |
| 218 | forceBrowser: true, | |
| 219 | }); | |
| 220 | ||
| 221 | expect(fallbackToBrowser).toHaveBeenCalledOnce(); | |
| 222 | expect(openPreview).not.toHaveBeenCalled(); | |
| 223 | }); | |
| 202 | 224 | }); |
apps/web/src/components/preview/openTerminalLinkInPreview.ts+5-5| ... | ... | @@ -34,19 +34,19 @@ interface OpenTerminalLinkInPreviewInput<E> { |
| 34 | 34 | readonly threadRef: ScopedThreadRef; |
| 35 | 35 | readonly openPreview: OpenPreviewMutation<E>; |
| 36 | 36 | readonly fallbackToBrowser: () => void; |
| 37 | /** Cmd/Ctrl-click bypasses the preference and opens in the system browser. */ | |
| 38 | readonly forceBrowser: boolean; | |
| 37 | 39 | } |
| 38 | 40 | |
| 39 | 41 | /** |
| 40 | * Opens a terminal hyperlink where the "Open links in" setting says. Terminal | |
| 41 | * links are activated with the platform modifier already held, so unlike chat | |
| 42 | * links the modifier cannot double as the system-browser override; the setting | |
| 43 | * alone decides, and the system browser is the fallback whenever the in-app | |
| 44 | * one cannot take the URL. | |
| 42 | * Opens a terminal hyperlink where the "Open links in" setting says, unless a | |
| 43 | * Cmd/Ctrl-click explicitly requests the system browser. | |
| 45 | 44 | */ |
| 46 | 45 | export async function openTerminalLinkInPreview<E>( |
| 47 | 46 | input: OpenTerminalLinkInPreviewInput<E>, |
| 48 | 47 | ): Promise<void> { |
| 49 | 48 | const supportsPreview = |
| 49 | !input.forceBrowser && | |
| 50 | 50 | isWebUrl(input.url) && |
| 51 | 51 | isPreviewSupportedInRuntime() && |
| 52 | 52 | input.threadRef.threadId.length > 0 && |
apps/web/src/components/settings/IntegrationsSettings.tsx+1-1| ... | ... | @@ -516,7 +516,7 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) |
| 516 | 516 | return ( |
| 517 | 517 | <SettingsRow |
| 518 | 518 | {...searchableSetting("browser-link-target")} |
| 519 | description="Where links in the chat and terminal open. Hold ⌘ or Ctrl while clicking a chat link to open it in your default browser either way." | |
| 519 | description="Where links in the chat and terminal open. Hold ⌘ or Ctrl while clicking a link to open it in your default browser either way." | |
| 520 | 520 | resetAction={ |
| 521 | 521 | !disabled && linkTarget !== DEFAULT_BROWSER_LINK_TARGET ? ( |
| 522 | 522 | <SettingResetButton |
apps/web/src/terminal/ghostty/surface.test.ts+80-15| ... | ... | @@ -12,7 +12,6 @@ import { |
| 12 | 12 | isTerminalCompositionCommitInput, |
| 13 | 13 | isTerminalCompositionKey, |
| 14 | 14 | isTerminalCopyShortcut, |
| 15 | isTerminalLinkPointerGesture, | |
| 16 | 15 | isTerminalPasteShortcut, |
| 17 | 16 | loadTerminalFontFamily, |
| 18 | 17 | primeTerminalCopyInput, |
| ... | ... | @@ -168,7 +167,7 @@ describe("GhosttyTerminalSurface visibility", () => { |
| 168 | 167 | resize() { |
| 169 | 168 | for (const callback of resizeCallbacks) callback(); |
| 170 | 169 | }, |
| 171 | pointer(type: string, clientX: number, buttons: number) { | |
| 170 | pointer(type: string, clientX: number, buttons: number, shiftKey = false) { | |
| 172 | 171 | canvas.dispatchEvent( |
| 173 | 172 | Object.assign(new Event(type, { cancelable: true }), { |
| 174 | 173 | clientX, |
| ... | ... | @@ -176,6 +175,7 @@ describe("GhosttyTerminalSurface visibility", () => { |
| 176 | 175 | pointerId: 1, |
| 177 | 176 | button: 0, |
| 178 | 177 | buttons, |
| 178 | shiftKey, | |
| 179 | 179 | }), |
| 180 | 180 | ); |
| 181 | 181 | }, |
| ... | ... | @@ -280,6 +280,84 @@ describe("GhosttyTerminalSurface visibility", () => { |
| 280 | 280 | expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false); |
| 281 | 281 | }); |
| 282 | 282 | |
| 283 | it("starts a selection when dragging from a link", async () => { | |
| 284 | const harness = createHarness(); | |
| 285 | const onLinkActivate = vi.fn(); | |
| 286 | const surface = await harness.create({ onLinkActivate }); | |
| 287 | surface.write("https://example.com"); | |
| 288 | harness.flushFrame(); | |
| 289 | ||
| 290 | harness.pointer("pointerdown", 5, 1); | |
| 291 | harness.pointer("pointermove", 37, 1); | |
| 292 | harness.pointer("pointerup", 37, 0); | |
| 293 | ||
| 294 | expect(onLinkActivate).not.toHaveBeenCalled(); | |
| 295 | expect(surface.getSelection()).toBe("https"); | |
| 296 | }); | |
| 297 | ||
| 298 | it("keeps a link click active through slight pointer movement", async () => { | |
| 299 | const harness = createHarness(); | |
| 300 | const onLinkActivate = vi.fn(); | |
| 301 | const surface = await harness.create({ onLinkActivate }); | |
| 302 | surface.write("https://example.com"); | |
| 303 | harness.flushFrame(); | |
| 304 | ||
| 305 | harness.pointer("pointerdown", 5, 1); | |
| 306 | harness.pointer("pointermove", 6, 1); | |
| 307 | harness.pointer("pointerup", 6, 0); | |
| 308 | ||
| 309 | expect(onLinkActivate).toHaveBeenCalledOnce(); | |
| 310 | }); | |
| 311 | ||
| 312 | it("uses repeated link clicks for word and line selection", async () => { | |
| 313 | const harness = createHarness(); | |
| 314 | const onLinkActivate = vi.fn(); | |
| 315 | const surface = await harness.create({ onLinkActivate }); | |
| 316 | surface.write("https://example.com tail"); | |
| 317 | harness.flushFrame(); | |
| 318 | ||
| 319 | harness.pointer("pointerdown", 5, 1); | |
| 320 | harness.pointer("pointerup", 5, 0); | |
| 321 | harness.pointer("pointerdown", 5, 1); | |
| 322 | harness.pointer("pointerup", 5, 0); | |
| 323 | expect(onLinkActivate).toHaveBeenCalledOnce(); | |
| 324 | expect(surface.getSelection()).not.toBe(""); | |
| 325 | ||
| 326 | harness.pointer("pointerdown", 5, 1); | |
| 327 | harness.pointer("pointerup", 5, 0); | |
| 328 | expect(onLinkActivate).toHaveBeenCalledOnce(); | |
| 329 | expect(surface.getSelection()).toBe("https://example.com tail"); | |
| 330 | }); | |
| 331 | ||
| 332 | it("uses Shift drags over links for selection", async () => { | |
| 333 | const harness = createHarness(); | |
| 334 | const onLinkActivate = vi.fn(); | |
| 335 | const surface = await harness.create({ onLinkActivate }); | |
| 336 | surface.write("https://example.com"); | |
| 337 | harness.flushFrame(); | |
| 338 | ||
| 339 | harness.pointer("pointerdown", 5, 1, true); | |
| 340 | harness.pointer("pointermove", 37, 1, true); | |
| 341 | harness.pointer("pointerup", 37, 0, true); | |
| 342 | expect(onLinkActivate).not.toHaveBeenCalled(); | |
| 343 | expect(surface.getSelection()).toBe("https"); | |
| 344 | }); | |
| 345 | ||
| 346 | it("does not activate a link replaced before pointer release", async () => { | |
| 347 | const harness = createHarness(); | |
| 348 | const onLinkActivate = vi.fn(); | |
| 349 | const surface = await harness.create({ onLinkActivate }); | |
| 350 | surface.write("https://first.example"); | |
| 351 | harness.flushFrame(); | |
| 352 | ||
| 353 | harness.pointer("pointerdown", 5, 1); | |
| 354 | surface.write("\x1b[2J\x1b[Hhttps://second.example"); | |
| 355 | harness.flushFrame(); | |
| 356 | harness.pointer("pointerup", 5, 0); | |
| 357 | ||
| 358 | expect(onLinkActivate).not.toHaveBeenCalled(); | |
| 359 | }); | |
| 360 | ||
| 283 | 361 | it("stops zero-size mounts and repaints when the same size returns", async () => { |
| 284 | 362 | const harness = createHarness(); |
| 285 | 363 | const surface = await harness.create(); |
| ... | ... | @@ -890,19 +968,6 @@ describe("terminalWheelArrowData", () => { |
| 890 | 968 | }); |
| 891 | 969 | }); |
| 892 | 970 | |
| 893 | describe("isTerminalLinkPointerGesture", () => { | |
| 894 | it("uses Command on macOS and Control elsewhere", () => { | |
| 895 | expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "MacIntel")).toBe(true); | |
| 896 | expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "MacIntel")).toBe(false); | |
| 897 | expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "Linux x86_64")).toBe( | |
| 898 | true, | |
| 899 | ); | |
| 900 | expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "Linux x86_64")).toBe( | |
| 901 | false, | |
| 902 | ); | |
| 903 | }); | |
| 904 | }); | |
| 905 | ||
| 906 | 971 | describe("advanceTerminalSelectionClickSequence", () => { |
| 907 | 972 | it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => { |
| 908 | 973 | const first = advanceTerminalSelectionClickSequence(null, { |
apps/web/src/terminal/ghostty/surface.ts+74-34| ... | ... | @@ -249,6 +249,19 @@ export interface TerminalLinkWithRange { |
| 249 | 249 | readonly range: GhosttyCellRange; |
| 250 | 250 | } |
| 251 | 251 | |
| 252 | function isSameTerminalLink( | |
| 253 | left: TerminalLinkWithRange, | |
| 254 | right: TerminalLinkWithRange | null, | |
| 255 | ): boolean { | |
| 256 | return ( | |
| 257 | right?.text === left.text && | |
| 258 | right.range.start.x === left.range.start.x && | |
| 259 | right.range.start.y === left.range.start.y && | |
| 260 | right.range.end.x === left.range.end.x && | |
| 261 | right.range.end.y === left.range.end.y | |
| 262 | ); | |
| 263 | } | |
| 264 | ||
| 252 | 265 | function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number { |
| 253 | 266 | for (let column = 0; column < row.cells.length; column += 1) { |
| 254 | 267 | const nextOffset = terminalColumnOffset(row, column + 1); |
| ... | ... | @@ -467,15 +480,6 @@ export function terminalWheelArrowData(rows: number, applicationCursorKeys: bool |
| 467 | 480 | return sequence.repeat(Math.abs(rows)); |
| 468 | 481 | } |
| 469 | 482 | |
| 470 | export function isTerminalLinkPointerGesture( | |
| 471 | event: Pick<MouseEvent, "ctrlKey" | "metaKey">, | |
| 472 | platform = navigator.platform, | |
| 473 | ): boolean { | |
| 474 | return isMacPlatform(platform) | |
| 475 | ? event.metaKey && !event.ctrlKey | |
| 476 | : event.ctrlKey && !event.metaKey; | |
| 477 | } | |
| 478 | ||
| 479 | 483 | export function ghosttyMouseButton(button: number): number | null { |
| 480 | 484 | switch (button) { |
| 481 | 485 | case 0: |
| ... | ... | @@ -500,6 +504,8 @@ export interface TerminalSelectionClickSequence { |
| 500 | 504 | readonly y: number; |
| 501 | 505 | } |
| 502 | 506 | |
| 507 | const TERMINAL_LINK_DRAG_THRESHOLD_PX = 4; | |
| 508 | ||
| 503 | 509 | export function advanceTerminalSelectionClickSequence( |
| 504 | 510 | previous: TerminalSelectionClickSequence | null, |
| 505 | 511 | event: Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">, |
| ... | ... | @@ -507,7 +513,8 @@ export function advanceTerminalSelectionClickSequence( |
| 507 | 513 | const repeats = |
| 508 | 514 | previous !== null && |
| 509 | 515 | event.timeStamp - previous.time <= SELECTION_MULTI_CLICK_INTERVAL_MS && |
| 510 | Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= 4; | |
| 516 | Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= | |
| 517 | TERMINAL_LINK_DRAG_THRESHOLD_PX; | |
| 511 | 518 | return { |
| 512 | 519 | count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1, |
| 513 | 520 | time: event.timeStamp, |
| ... | ... | @@ -592,9 +599,14 @@ export class GhosttyTerminalSurface { |
| 592 | 599 | private mouseReportingPointerId: number | null = null; |
| 593 | 600 | private mouseReportingButton: number | null = null; |
| 594 | 601 | private linkActivationPointerId: number | null = null; |
| 602 | private linkActivationLink: TerminalLinkWithRange | null = null; | |
| 603 | private linkActivationOrigin: { | |
| 604 | x: number; | |
| 605 | y: number; | |
| 606 | clickCount: number; | |
| 607 | } | null = null; | |
| 595 | 608 | private hoveredLink: TerminalLinkWithRange | null = null; |
| 596 | 609 | private hoverPointer: { x: number; y: number } | null = null; |
| 597 | private linkModifierActive = false; | |
| 598 | 610 | private selectionClickSequence: TerminalSelectionClickSequence | null = null; |
| 599 | 611 | private selectionMoved = false; |
| 600 | 612 | private composing = false; |
| ... | ... | @@ -1018,7 +1030,6 @@ export class GhosttyTerminalSurface { |
| 1018 | 1030 | } |
| 1019 | 1031 | |
| 1020 | 1032 | private readonly onKeyDown = (event: KeyboardEvent) => { |
| 1021 | this.updateLinkModifier(event); | |
| 1022 | 1033 | // Presses handled outside the terminal must also swallow their release: |
| 1023 | 1034 | // beforeKey runs side effects (keybindings, navigation sends), so it cannot |
| 1024 | 1035 | // be consulted again on keyup, and Kitty report-event-types sessions would |
| ... | ... | @@ -1121,7 +1132,6 @@ export class GhosttyTerminalSurface { |
| 1121 | 1132 | }; |
| 1122 | 1133 | |
| 1123 | 1134 | private readonly onKeyUp = (event: KeyboardEvent) => { |
| 1124 | this.updateLinkModifier(event); | |
| 1125 | 1135 | if (this.suppressedKeyCodes.delete(event.code)) return; |
| 1126 | 1136 | if (isTerminalCompositionKey(event, this.composing)) { |
| 1127 | 1137 | return; |
| ... | ... | @@ -1143,7 +1153,6 @@ export class GhosttyTerminalSurface { |
| 1143 | 1153 | |
| 1144 | 1154 | private readonly onBlur = () => { |
| 1145 | 1155 | this.focused = false; |
| 1146 | this.linkModifierActive = false; | |
| 1147 | 1156 | this.refreshHoveredLink(); |
| 1148 | 1157 | // Suppressions survive blur deliberately: a shortcut that moves focus (for |
| 1149 | 1158 | // example terminal-toggle) must still swallow its own keyup if focus comes |
| ... | ... | @@ -1264,21 +1273,39 @@ export class GhosttyTerminalSurface { |
| 1264 | 1273 | return; |
| 1265 | 1274 | } |
| 1266 | 1275 | if (event.button !== 0) return; |
| 1267 | if (isTerminalLinkPointerGesture(event)) { | |
| 1276 | const clickCount = this.recordSelectionClick(event); | |
| 1277 | const link = this.linkAt(event.clientX, event.clientY); | |
| 1278 | if (link && !event.shiftKey && clickCount === 1) { | |
| 1268 | 1279 | event.preventDefault(); |
| 1269 | 1280 | event.stopPropagation(); |
| 1270 | 1281 | this.linkActivationPointerId = event.pointerId; |
| 1282 | this.linkActivationLink = link; | |
| 1283 | this.linkActivationOrigin = { | |
| 1284 | x: event.clientX, | |
| 1285 | y: event.clientY, | |
| 1286 | clickCount, | |
| 1287 | }; | |
| 1271 | 1288 | this.canvas.setPointerCapture(event.pointerId); |
| 1272 | 1289 | return; |
| 1273 | 1290 | } |
| 1274 | 1291 | this.clearHoveredLink(); |
| 1275 | const cell = this.cellAt(event.clientX, event.clientY); | |
| 1276 | this.selectionMoved = false; | |
| 1292 | this.beginSelection(event, clickCount); | |
| 1293 | this.canvas.setPointerCapture(event.pointerId); | |
| 1294 | }; | |
| 1295 | ||
| 1296 | private recordSelectionClick( | |
| 1297 | event: Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">, | |
| 1298 | ): number { | |
| 1277 | 1299 | this.selectionClickSequence = advanceTerminalSelectionClickSequence( |
| 1278 | 1300 | this.selectionClickSequence, |
| 1279 | 1301 | event, |
| 1280 | 1302 | ); |
| 1281 | const clickCount = this.selectionClickSequence.count; | |
| 1303 | return this.selectionClickSequence.count; | |
| 1304 | } | |
| 1305 | ||
| 1306 | private beginSelection(event: { clientX: number; clientY: number }, clickCount: number): void { | |
| 1307 | const cell = this.cellAt(event.clientX, event.clientY); | |
| 1308 | this.selectionMoved = false; | |
| 1282 | 1309 | this.selectionMode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell"; |
| 1283 | 1310 | const range = |
| 1284 | 1311 | this.selectionMode === "line" |
| ... | ... | @@ -1306,12 +1333,31 @@ export class GhosttyTerminalSurface { |
| 1306 | 1333 | } |
| 1307 | 1334 | } |
| 1308 | 1335 | this.forceFullRender = true; |
| 1309 | this.canvas.setPointerCapture(event.pointerId); | |
| 1310 | 1336 | this.requestRender(); |
| 1311 | }; | |
| 1337 | } | |
| 1312 | 1338 | |
| 1313 | 1339 | private readonly onPointerMove = (event: PointerEvent) => { |
| 1314 | if (this.linkActivationPointerId === event.pointerId) return; | |
| 1340 | if (this.linkActivationPointerId === event.pointerId) { | |
| 1341 | const origin = this.linkActivationOrigin; | |
| 1342 | if ( | |
| 1343 | origin === null || | |
| 1344 | Math.hypot(event.clientX - origin.x, event.clientY - origin.y) <= | |
| 1345 | TERMINAL_LINK_DRAG_THRESHOLD_PX | |
| 1346 | ) { | |
| 1347 | return; | |
| 1348 | } | |
| 1349 | this.linkActivationPointerId = null; | |
| 1350 | this.linkActivationLink = null; | |
| 1351 | this.linkActivationOrigin = null; | |
| 1352 | this.clearHoveredLink(); | |
| 1353 | this.beginSelection( | |
| 1354 | { | |
| 1355 | clientX: origin.x, | |
| 1356 | clientY: origin.y, | |
| 1357 | }, | |
| 1358 | origin.clickCount, | |
| 1359 | ); | |
| 1360 | } | |
| 1315 | 1361 | // Hover motion is only reportable in any-event tracking (DEC 1003); normal and |
| 1316 | 1362 | // button-event tracking never report motion without a captured pressed button. |
| 1317 | 1363 | const anyEventTracking = this.synchronizeMouseTrackingState(); |
| ... | ... | @@ -1321,7 +1367,6 @@ export class GhosttyTerminalSurface { |
| 1321 | 1367 | ) { |
| 1322 | 1368 | event.preventDefault(); |
| 1323 | 1369 | this.hoverPointer = { x: event.clientX, y: event.clientY }; |
| 1324 | this.linkModifierActive = isTerminalLinkPointerGesture(event); | |
| 1325 | 1370 | // A drag whose press was already sent to the terminal application cannot |
| 1326 | 1371 | // turn into link activation midway through, so link feedback would lie. |
| 1327 | 1372 | this.setHoveredLink(null); |
| ... | ... | @@ -1396,14 +1441,6 @@ export class GhosttyTerminalSurface { |
| 1396 | 1441 | |
| 1397 | 1442 | private updateHoverCursor(event: PointerEvent): void { |
| 1398 | 1443 | this.hoverPointer = { x: event.clientX, y: event.clientY }; |
| 1399 | this.linkModifierActive = isTerminalLinkPointerGesture(event); | |
| 1400 | this.refreshHoveredLink(); | |
| 1401 | } | |
| 1402 | ||
| 1403 | private updateLinkModifier(event: Pick<KeyboardEvent, "ctrlKey" | "metaKey">): void { | |
| 1404 | const active = isTerminalLinkPointerGesture(event); | |
| 1405 | if (active === this.linkModifierActive) return; | |
| 1406 | this.linkModifierActive = active; | |
| 1407 | 1444 | this.refreshHoveredLink(); |
| 1408 | 1445 | } |
| 1409 | 1446 | |
| ... | ... | @@ -1420,7 +1457,7 @@ export class GhosttyTerminalSurface { |
| 1420 | 1457 | |
| 1421 | 1458 | private refreshHoveredLink(): void { |
| 1422 | 1459 | const pointer = this.hoverPointer; |
| 1423 | const link = pointer && this.linkModifierActive ? this.linkAt(pointer.x, pointer.y) : null; | |
| 1460 | const link = pointer ? this.linkAt(pointer.x, pointer.y) : null; | |
| 1424 | 1461 | this.setHoveredLink(link); |
| 1425 | 1462 | } |
| 1426 | 1463 | |
| ... | ... | @@ -1444,13 +1481,17 @@ export class GhosttyTerminalSurface { |
| 1444 | 1481 | if (this.linkActivationPointerId === event.pointerId) { |
| 1445 | 1482 | event.preventDefault(); |
| 1446 | 1483 | event.stopPropagation(); |
| 1484 | const link = this.linkActivationLink; | |
| 1447 | 1485 | this.linkActivationPointerId = null; |
| 1486 | this.linkActivationLink = null; | |
| 1487 | this.linkActivationOrigin = null; | |
| 1448 | 1488 | if (this.canvas.hasPointerCapture(event.pointerId)) { |
| 1449 | 1489 | this.canvas.releasePointerCapture(event.pointerId); |
| 1450 | 1490 | } |
| 1451 | 1491 | if (event.type !== "pointercancel") { |
| 1452 | const link = this.linkAt(event.clientX, event.clientY); | |
| 1453 | if (link) this.options.onLinkActivate(link.text, event); | |
| 1492 | if (link && isSameTerminalLink(link, this.linkAt(event.clientX, event.clientY))) { | |
| 1493 | this.options.onLinkActivate(link.text, event); | |
| 1494 | } | |
| 1454 | 1495 | } |
| 1455 | 1496 | return; |
| 1456 | 1497 | } |
| ... | ... | @@ -1467,7 +1508,6 @@ export class GhosttyTerminalSurface { |
| 1467 | 1508 | this.clearHoveredLink(); |
| 1468 | 1509 | } else { |
| 1469 | 1510 | this.hoverPointer = { x: event.clientX, y: event.clientY }; |
| 1470 | this.linkModifierActive = isTerminalLinkPointerGesture(event); | |
| 1471 | 1511 | this.refreshHoveredLink(); |
| 1472 | 1512 | } |
| 1473 | 1513 | return; |