| 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,7 +57,7 @@ import { |
| 57 | } from "~/terminal/ghostty/surface"; | 57 | } from "~/terminal/ghostty/surface"; |
| 58 | import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; | 58 | import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; |
| 59 | import { useOpenInPreferredEditor } from "../editorPreferences"; | 59 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 60 | import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; | 60 | import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; |
| 61 | import { | 61 | import { |
| 62 | isDiffToggleShortcut, | 62 | isDiffToggleShortcut, |
| 63 | isTerminalClearShortcut, | 63 | isTerminalClearShortcut, |
| ... | @@ -782,7 +782,6 @@ export function TerminalViewport({ | ... | @@ -782,7 +782,6 @@ export function TerminalViewport({ |
| 782 | } | 782 | } |
| 783 | 783 | ||
| 784 | function handleLinkActivate(text: string, event: MouseEvent): void { | 784 | function handleLinkActivate(text: string, event: MouseEvent): void { |
| 785 | if (!isTerminalLinkActivation(event)) return; | ||
| 786 | const latestTerminal = terminalRef.current; | 785 | const latestTerminal = terminalRef.current; |
| 787 | if (!latestTerminal) return; | 786 | if (!latestTerminal) return; |
| 788 | if (isTerminalUrl(text)) { | 787 | if (isTerminalUrl(text)) { |
| ... | @@ -803,6 +802,7 @@ export function TerminalViewport({ | ... | @@ -803,6 +802,7 @@ export function TerminalViewport({ |
| 803 | threadRef, | 802 | threadRef, |
| 804 | openPreview, | 803 | openPreview, |
| 805 | fallbackToBrowser, | 804 | fallbackToBrowser, |
| 805 | forceBrowser: event.metaKey || event.ctrlKey, | ||
| 806 | }).catch((error: unknown) => { | 806 | }).catch((error: unknown) => { |
| 807 | toastManager.add( | 807 | toastManager.add( |
| 808 | stackedThreadToast({ | 808 | stackedThreadToast({ |
apps/web/src/components/preview/openTerminalLinkInPreview.test.ts+22| ... | @@ -88,6 +88,7 @@ describe("openTerminalLinkInPreview", () => { | ... | @@ -88,6 +88,7 @@ describe("openTerminalLinkInPreview", () => { |
| 88 | threadRef, | 88 | threadRef, |
| 89 | openPreview, | 89 | openPreview, |
| 90 | fallbackToBrowser, | 90 | fallbackToBrowser, |
| 91 | forceBrowser: false, | ||
| 91 | }), | 92 | }), |
| 92 | ).rejects.toBe(failure); | 93 | ).rejects.toBe(failure); |
| 93 | expect(fallbackToBrowser).not.toHaveBeenCalled(); | 94 | expect(fallbackToBrowser).not.toHaveBeenCalled(); |
| ... | @@ -105,6 +106,7 @@ describe("openTerminalLinkInPreview", () => { | ... | @@ -105,6 +106,7 @@ describe("openTerminalLinkInPreview", () => { |
| 105 | threadRef, | 106 | threadRef, |
| 106 | openPreview, | 107 | openPreview, |
| 107 | fallbackToBrowser, | 108 | fallbackToBrowser, |
| 109 | forceBrowser: false, | ||
| 108 | }); | 110 | }); |
| 109 | 111 | ||
| 110 | expect(fallbackToBrowser).toHaveBeenCalledOnce(); | 112 | expect(fallbackToBrowser).toHaveBeenCalledOnce(); |
| ... | @@ -120,6 +122,7 @@ describe("openTerminalLinkInPreview", () => { | ... | @@ -120,6 +122,7 @@ describe("openTerminalLinkInPreview", () => { |
| 120 | threadRef, | 122 | threadRef, |
| 121 | openPreview, | 123 | openPreview, |
| 122 | fallbackToBrowser, | 124 | fallbackToBrowser, |
| 125 | forceBrowser: false, | ||
| 123 | }); | 126 | }); |
| 124 | 127 | ||
| 125 | expect(openPreview).toHaveBeenCalledOnce(); | 128 | expect(openPreview).toHaveBeenCalledOnce(); |
| ... | @@ -141,6 +144,7 @@ describe("openTerminalLinkInPreview", () => { | ... | @@ -141,6 +144,7 @@ describe("openTerminalLinkInPreview", () => { |
| 141 | threadRef, | 144 | threadRef, |
| 142 | openPreview, | 145 | openPreview, |
| 143 | fallbackToBrowser: vi.fn(), | 146 | fallbackToBrowser: vi.fn(), |
| 147 | forceBrowser: false, | ||
| 144 | }); | 148 | }); |
| 145 | 149 | ||
| 146 | await vi.waitFor(() => expect(browserDefaultsMocks.resolve).toHaveBeenCalledOnce()); | 150 | await vi.waitFor(() => expect(browserDefaultsMocks.resolve).toHaveBeenCalledOnce()); |
| ... | @@ -170,6 +174,7 @@ describe("openTerminalLinkInPreview", () => { | ... | @@ -170,6 +174,7 @@ describe("openTerminalLinkInPreview", () => { |
| 170 | threadRef, | 174 | threadRef, |
| 171 | openPreview: async () => AsyncResult.failure(cause), | 175 | openPreview: async () => AsyncResult.failure(cause), |
| 172 | fallbackToBrowser, | 176 | fallbackToBrowser, |
| 177 | forceBrowser: false, | ||
| 173 | }); | 178 | }); |
| 174 | 179 | ||
| 175 | expect(fallbackToBrowser).toHaveBeenCalledOnce(); | 180 | expect(fallbackToBrowser).toHaveBeenCalledOnce(); |
| ... | @@ -194,9 +199,26 @@ describe("openTerminalLinkInPreview", () => { | ... | @@ -194,9 +199,26 @@ describe("openTerminalLinkInPreview", () => { |
| 194 | threadRef, | 199 | threadRef, |
| 195 | openPreview: async () => AsyncResult.failure(Cause.interrupt()), | 200 | openPreview: async () => AsyncResult.failure(Cause.interrupt()), |
| 196 | fallbackToBrowser, | 201 | fallbackToBrowser, |
| 202 | forceBrowser: false, | ||
| 197 | }); | 203 | }); |
| 198 | 204 | ||
| 199 | expect(reportError).not.toHaveBeenCalled(); | 205 | expect(reportError).not.toHaveBeenCalled(); |
| 200 | expect(fallbackToBrowser).not.toHaveBeenCalled(); | 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,19 +34,19 @@ interface OpenTerminalLinkInPreviewInput<E> { |
| 34 | readonly threadRef: ScopedThreadRef; | 34 | readonly threadRef: ScopedThreadRef; |
| 35 | readonly openPreview: OpenPreviewMutation<E>; | 35 | readonly openPreview: OpenPreviewMutation<E>; |
| 36 | readonly fallbackToBrowser: () => void; | 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 | 42 | * Opens a terminal hyperlink where the "Open links in" setting says, unless a |
| 41 | * links are activated with the platform modifier already held, so unlike chat | 43 | * Cmd/Ctrl-click explicitly requests the system browser. |
| 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. | ||
| 45 | */ | 44 | */ |
| 46 | export async function openTerminalLinkInPreview<E>( | 45 | export async function openTerminalLinkInPreview<E>( |
| 47 | input: OpenTerminalLinkInPreviewInput<E>, | 46 | input: OpenTerminalLinkInPreviewInput<E>, |
| 48 | ): Promise<void> { | 47 | ): Promise<void> { |
| 49 | const supportsPreview = | 48 | const supportsPreview = |
| 49 | !input.forceBrowser && | ||
| 50 | isWebUrl(input.url) && | 50 | isWebUrl(input.url) && |
| 51 | isPreviewSupportedInRuntime() && | 51 | isPreviewSupportedInRuntime() && |
| 52 | input.threadRef.threadId.length > 0 && | 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,7 +516,7 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) |
| 516 | return ( | 516 | return ( |
| 517 | <SettingsRow | 517 | <SettingsRow |
| 518 | {...searchableSetting("browser-link-target")} | 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 | resetAction={ | 520 | resetAction={ |
| 521 | !disabled && linkTarget !== DEFAULT_BROWSER_LINK_TARGET ? ( | 521 | !disabled && linkTarget !== DEFAULT_BROWSER_LINK_TARGET ? ( |
| 522 | <SettingResetButton | 522 | <SettingResetButton |
apps/web/src/terminal/ghostty/surface.test.ts+80-15| ... | @@ -12,7 +12,6 @@ import { | ... | @@ -12,7 +12,6 @@ import { |
| 12 | isTerminalCompositionCommitInput, | 12 | isTerminalCompositionCommitInput, |
| 13 | isTerminalCompositionKey, | 13 | isTerminalCompositionKey, |
| 14 | isTerminalCopyShortcut, | 14 | isTerminalCopyShortcut, |
| 15 | isTerminalLinkPointerGesture, | ||
| 16 | isTerminalPasteShortcut, | 15 | isTerminalPasteShortcut, |
| 17 | loadTerminalFontFamily, | 16 | loadTerminalFontFamily, |
| 18 | primeTerminalCopyInput, | 17 | primeTerminalCopyInput, |
| ... | @@ -168,7 +167,7 @@ describe("GhosttyTerminalSurface visibility", () => { | ... | @@ -168,7 +167,7 @@ describe("GhosttyTerminalSurface visibility", () => { |
| 168 | resize() { | 167 | resize() { |
| 169 | for (const callback of resizeCallbacks) callback(); | 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 | canvas.dispatchEvent( | 171 | canvas.dispatchEvent( |
| 173 | Object.assign(new Event(type, { cancelable: true }), { | 172 | Object.assign(new Event(type, { cancelable: true }), { |
| 174 | clientX, | 173 | clientX, |
| ... | @@ -176,6 +175,7 @@ describe("GhosttyTerminalSurface visibility", () => { | ... | @@ -176,6 +175,7 @@ describe("GhosttyTerminalSurface visibility", () => { |
| 176 | pointerId: 1, | 175 | pointerId: 1, |
| 177 | button: 0, | 176 | button: 0, |
| 178 | buttons, | 177 | buttons, |
| 178 | shiftKey, | ||
| 179 | }), | 179 | }), |
| 180 | ); | 180 | ); |
| 181 | }, | 181 | }, |
| ... | @@ -280,6 +280,84 @@ describe("GhosttyTerminalSurface visibility", () => { | ... | @@ -280,6 +280,84 @@ describe("GhosttyTerminalSurface visibility", () => { |
| 280 | expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false); | 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 | it("stops zero-size mounts and repaints when the same size returns", async () => { | 361 | it("stops zero-size mounts and repaints when the same size returns", async () => { |
| 284 | const harness = createHarness(); | 362 | const harness = createHarness(); |
| 285 | const surface = await harness.create(); | 363 | const surface = await harness.create(); |
| ... | @@ -890,19 +968,6 @@ describe("terminalWheelArrowData", () => { | ... | @@ -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 | describe("advanceTerminalSelectionClickSequence", () => { | 971 | describe("advanceTerminalSelectionClickSequence", () => { |
| 907 | it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => { | 972 | it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => { |
| 908 | const first = advanceTerminalSelectionClickSequence(null, { | 973 | const first = advanceTerminalSelectionClickSequence(null, { |
apps/web/src/terminal/ghostty/surface.ts+74-34| ... | @@ -249,6 +249,19 @@ export interface TerminalLinkWithRange { | ... | @@ -249,6 +249,19 @@ export interface TerminalLinkWithRange { |
| 249 | readonly range: GhosttyCellRange; | 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 | function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number { | 265 | function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number { |
| 253 | for (let column = 0; column < row.cells.length; column += 1) { | 266 | for (let column = 0; column < row.cells.length; column += 1) { |
| 254 | const nextOffset = terminalColumnOffset(row, column + 1); | 267 | const nextOffset = terminalColumnOffset(row, column + 1); |
| ... | @@ -467,15 +480,6 @@ export function terminalWheelArrowData(rows: number, applicationCursorKeys: bool | ... | @@ -467,15 +480,6 @@ export function terminalWheelArrowData(rows: number, applicationCursorKeys: bool |
| 467 | return sequence.repeat(Math.abs(rows)); | 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 | export function ghosttyMouseButton(button: number): number | null { | 483 | export function ghosttyMouseButton(button: number): number | null { |
| 480 | switch (button) { | 484 | switch (button) { |
| 481 | case 0: | 485 | case 0: |
| ... | @@ -500,6 +504,8 @@ export interface TerminalSelectionClickSequence { | ... | @@ -500,6 +504,8 @@ export interface TerminalSelectionClickSequence { |
| 500 | readonly y: number; | 504 | readonly y: number; |
| 501 | } | 505 | } |
| 502 | 506 | ||
| 507 | const TERMINAL_LINK_DRAG_THRESHOLD_PX = 4; | ||
| 508 | |||
| 503 | export function advanceTerminalSelectionClickSequence( | 509 | export function advanceTerminalSelectionClickSequence( |
| 504 | previous: TerminalSelectionClickSequence | null, | 510 | previous: TerminalSelectionClickSequence | null, |
| 505 | event: Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">, | 511 | event: Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">, |
| ... | @@ -507,7 +513,8 @@ export function advanceTerminalSelectionClickSequence( | ... | @@ -507,7 +513,8 @@ export function advanceTerminalSelectionClickSequence( |
| 507 | const repeats = | 513 | const repeats = |
| 508 | previous !== null && | 514 | previous !== null && |
| 509 | event.timeStamp - previous.time <= SELECTION_MULTI_CLICK_INTERVAL_MS && | 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 | return { | 518 | return { |
| 512 | count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1, | 519 | count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1, |
| 513 | time: event.timeStamp, | 520 | time: event.timeStamp, |
| ... | @@ -592,9 +599,14 @@ export class GhosttyTerminalSurface { | ... | @@ -592,9 +599,14 @@ export class GhosttyTerminalSurface { |
| 592 | private mouseReportingPointerId: number | null = null; | 599 | private mouseReportingPointerId: number | null = null; |
| 593 | private mouseReportingButton: number | null = null; | 600 | private mouseReportingButton: number | null = null; |
| 594 | private linkActivationPointerId: number | null = null; | 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 | private hoveredLink: TerminalLinkWithRange | null = null; | 608 | private hoveredLink: TerminalLinkWithRange | null = null; |
| 596 | private hoverPointer: { x: number; y: number } | null = null; | 609 | private hoverPointer: { x: number; y: number } | null = null; |
| 597 | private linkModifierActive = false; | ||
| 598 | private selectionClickSequence: TerminalSelectionClickSequence | null = null; | 610 | private selectionClickSequence: TerminalSelectionClickSequence | null = null; |
| 599 | private selectionMoved = false; | 611 | private selectionMoved = false; |
| 600 | private composing = false; | 612 | private composing = false; |
| ... | @@ -1018,7 +1030,6 @@ export class GhosttyTerminalSurface { | ... | @@ -1018,7 +1030,6 @@ export class GhosttyTerminalSurface { |
| 1018 | } | 1030 | } |
| 1019 | 1031 | ||
| 1020 | private readonly onKeyDown = (event: KeyboardEvent) => { | 1032 | private readonly onKeyDown = (event: KeyboardEvent) => { |
| 1021 | this.updateLinkModifier(event); | ||
| 1022 | // Presses handled outside the terminal must also swallow their release: | 1033 | // Presses handled outside the terminal must also swallow their release: |
| 1023 | // beforeKey runs side effects (keybindings, navigation sends), so it cannot | 1034 | // beforeKey runs side effects (keybindings, navigation sends), so it cannot |
| 1024 | // be consulted again on keyup, and Kitty report-event-types sessions would | 1035 | // be consulted again on keyup, and Kitty report-event-types sessions would |
| ... | @@ -1121,7 +1132,6 @@ export class GhosttyTerminalSurface { | ... | @@ -1121,7 +1132,6 @@ export class GhosttyTerminalSurface { |
| 1121 | }; | 1132 | }; |
| 1122 | 1133 | ||
| 1123 | private readonly onKeyUp = (event: KeyboardEvent) => { | 1134 | private readonly onKeyUp = (event: KeyboardEvent) => { |
| 1124 | this.updateLinkModifier(event); | ||
| 1125 | if (this.suppressedKeyCodes.delete(event.code)) return; | 1135 | if (this.suppressedKeyCodes.delete(event.code)) return; |
| 1126 | if (isTerminalCompositionKey(event, this.composing)) { | 1136 | if (isTerminalCompositionKey(event, this.composing)) { |
| 1127 | return; | 1137 | return; |
| ... | @@ -1143,7 +1153,6 @@ export class GhosttyTerminalSurface { | ... | @@ -1143,7 +1153,6 @@ export class GhosttyTerminalSurface { |
| 1143 | 1153 | ||
| 1144 | private readonly onBlur = () => { | 1154 | private readonly onBlur = () => { |
| 1145 | this.focused = false; | 1155 | this.focused = false; |
| 1146 | this.linkModifierActive = false; | ||
| 1147 | this.refreshHoveredLink(); | 1156 | this.refreshHoveredLink(); |
| 1148 | // Suppressions survive blur deliberately: a shortcut that moves focus (for | 1157 | // Suppressions survive blur deliberately: a shortcut that moves focus (for |
| 1149 | // example terminal-toggle) must still swallow its own keyup if focus comes | 1158 | // example terminal-toggle) must still swallow its own keyup if focus comes |
| ... | @@ -1264,21 +1273,39 @@ export class GhosttyTerminalSurface { | ... | @@ -1264,21 +1273,39 @@ export class GhosttyTerminalSurface { |
| 1264 | return; | 1273 | return; |
| 1265 | } | 1274 | } |
| 1266 | if (event.button !== 0) return; | 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 | event.preventDefault(); | 1279 | event.preventDefault(); |
| 1269 | event.stopPropagation(); | 1280 | event.stopPropagation(); |
| 1270 | this.linkActivationPointerId = event.pointerId; | 1281 | this.linkActivationPointerId = event.pointerId; |
| 1282 | this.linkActivationLink = link; | ||
| 1283 | this.linkActivationOrigin = { | ||
| 1284 | x: event.clientX, | ||
| 1285 | y: event.clientY, | ||
| 1286 | clickCount, | ||
| 1287 | }; | ||
| 1271 | this.canvas.setPointerCapture(event.pointerId); | 1288 | this.canvas.setPointerCapture(event.pointerId); |
| 1272 | return; | 1289 | return; |
| 1273 | } | 1290 | } |
| 1274 | this.clearHoveredLink(); | 1291 | this.clearHoveredLink(); |
| 1275 | const cell = this.cellAt(event.clientX, event.clientY); | 1292 | this.beginSelection(event, clickCount); |
| 1276 | this.selectionMoved = false; | 1293 | this.canvas.setPointerCapture(event.pointerId); |
| 1294 | }; | ||
| 1295 | |||
| 1296 | private recordSelectionClick( | ||
| 1297 | event: Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">, | ||
| 1298 | ): number { | ||
| 1277 | this.selectionClickSequence = advanceTerminalSelectionClickSequence( | 1299 | this.selectionClickSequence = advanceTerminalSelectionClickSequence( |
| 1278 | this.selectionClickSequence, | 1300 | this.selectionClickSequence, |
| 1279 | event, | 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 | this.selectionMode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell"; | 1309 | this.selectionMode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell"; |
| 1283 | const range = | 1310 | const range = |
| 1284 | this.selectionMode === "line" | 1311 | this.selectionMode === "line" |
| ... | @@ -1306,12 +1333,31 @@ export class GhosttyTerminalSurface { | ... | @@ -1306,12 +1333,31 @@ export class GhosttyTerminalSurface { |
| 1306 | } | 1333 | } |
| 1307 | } | 1334 | } |
| 1308 | this.forceFullRender = true; | 1335 | this.forceFullRender = true; |
| 1309 | this.canvas.setPointerCapture(event.pointerId); | ||
| 1310 | this.requestRender(); | 1336 | this.requestRender(); |
| 1311 | }; | 1337 | } |
| 1312 | 1338 | ||
| 1313 | private readonly onPointerMove = (event: PointerEvent) => { | 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 | // Hover motion is only reportable in any-event tracking (DEC 1003); normal and | 1361 | // Hover motion is only reportable in any-event tracking (DEC 1003); normal and |
| 1316 | // button-event tracking never report motion without a captured pressed button. | 1362 | // button-event tracking never report motion without a captured pressed button. |
| 1317 | const anyEventTracking = this.synchronizeMouseTrackingState(); | 1363 | const anyEventTracking = this.synchronizeMouseTrackingState(); |
| ... | @@ -1321,7 +1367,6 @@ export class GhosttyTerminalSurface { | ... | @@ -1321,7 +1367,6 @@ export class GhosttyTerminalSurface { |
| 1321 | ) { | 1367 | ) { |
| 1322 | event.preventDefault(); | 1368 | event.preventDefault(); |
| 1323 | this.hoverPointer = { x: event.clientX, y: event.clientY }; | 1369 | this.hoverPointer = { x: event.clientX, y: event.clientY }; |
| 1324 | this.linkModifierActive = isTerminalLinkPointerGesture(event); | ||
| 1325 | // A drag whose press was already sent to the terminal application cannot | 1370 | // A drag whose press was already sent to the terminal application cannot |
| 1326 | // turn into link activation midway through, so link feedback would lie. | 1371 | // turn into link activation midway through, so link feedback would lie. |
| 1327 | this.setHoveredLink(null); | 1372 | this.setHoveredLink(null); |
| ... | @@ -1396,14 +1441,6 @@ export class GhosttyTerminalSurface { | ... | @@ -1396,14 +1441,6 @@ export class GhosttyTerminalSurface { |
| 1396 | 1441 | ||
| 1397 | private updateHoverCursor(event: PointerEvent): void { | 1442 | private updateHoverCursor(event: PointerEvent): void { |
| 1398 | this.hoverPointer = { x: event.clientX, y: event.clientY }; | 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 | this.refreshHoveredLink(); | 1444 | this.refreshHoveredLink(); |
| 1408 | } | 1445 | } |
| 1409 | 1446 | ||
| ... | @@ -1420,7 +1457,7 @@ export class GhosttyTerminalSurface { | ... | @@ -1420,7 +1457,7 @@ export class GhosttyTerminalSurface { |
| 1420 | 1457 | ||
| 1421 | private refreshHoveredLink(): void { | 1458 | private refreshHoveredLink(): void { |
| 1422 | const pointer = this.hoverPointer; | 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 | this.setHoveredLink(link); | 1461 | this.setHoveredLink(link); |
| 1425 | } | 1462 | } |
| 1426 | 1463 | ||
| ... | @@ -1444,13 +1481,17 @@ export class GhosttyTerminalSurface { | ... | @@ -1444,13 +1481,17 @@ export class GhosttyTerminalSurface { |
| 1444 | if (this.linkActivationPointerId === event.pointerId) { | 1481 | if (this.linkActivationPointerId === event.pointerId) { |
| 1445 | event.preventDefault(); | 1482 | event.preventDefault(); |
| 1446 | event.stopPropagation(); | 1483 | event.stopPropagation(); |
| 1484 | const link = this.linkActivationLink; | ||
| 1447 | this.linkActivationPointerId = null; | 1485 | this.linkActivationPointerId = null; |
| 1486 | this.linkActivationLink = null; | ||
| 1487 | this.linkActivationOrigin = null; | ||
| 1448 | if (this.canvas.hasPointerCapture(event.pointerId)) { | 1488 | if (this.canvas.hasPointerCapture(event.pointerId)) { |
| 1449 | this.canvas.releasePointerCapture(event.pointerId); | 1489 | this.canvas.releasePointerCapture(event.pointerId); |
| 1450 | } | 1490 | } |
| 1451 | if (event.type !== "pointercancel") { | 1491 | if (event.type !== "pointercancel") { |
| 1452 | const link = this.linkAt(event.clientX, event.clientY); | 1492 | if (link && isSameTerminalLink(link, this.linkAt(event.clientX, event.clientY))) { |
| 1453 | if (link) this.options.onLinkActivate(link.text, event); | 1493 | this.options.onLinkActivate(link.text, event); |
| 1494 | } | ||
| 1454 | } | 1495 | } |
| 1455 | return; | 1496 | return; |
| 1456 | } | 1497 | } |
| ... | @@ -1467,7 +1508,6 @@ export class GhosttyTerminalSurface { | ... | @@ -1467,7 +1508,6 @@ export class GhosttyTerminalSurface { |
| 1467 | this.clearHoveredLink(); | 1508 | this.clearHoveredLink(); |
| 1468 | } else { | 1509 | } else { |
| 1469 | this.hoverPointer = { x: event.clientX, y: event.clientY }; | 1510 | this.hoverPointer = { x: event.clientX, y: event.clientY }; |
| 1470 | this.linkModifierActive = isTerminalLinkPointerGesture(event); | ||
| 1471 | this.refreshHoveredLink(); | 1511 | this.refreshHoveredLink(); |
| 1472 | } | 1512 | } |
| 1473 | return; | 1513 | return; |