| author | |
| committer | |
| log | 21e80a06364898b8ecffad8aeddcb5634170260b |
| tree | 728d398e51d05fae13452e36c8862f6be912ee14 |
| parent | 8824f8f24f8b81fd6aed6f228a57a1d4013d01d9 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 269 insertions(+), 16 deletions(-)
apps/web/src/terminal/ghostty/runtimeAbi.test.ts+83| ... | @@ -320,6 +320,89 @@ describe("vendored libghostty-vt WebAssembly", () => { | ... | @@ -320,6 +320,89 @@ describe("vendored libghostty-vt WebAssembly", () => { |
| 320 | call("ghostty_wasm_free_u8_array", options, 8); | 320 | call("ghostty_wasm_free_u8_array", options, 8); |
| 321 | }); | 321 | }); |
| 322 | 322 | ||
| 323 | it("formats a cell-drag selection installed from screen grid refs", async () => { | ||
| 324 | const result = await WebAssembly.instantiate( | ||
| 325 | decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, | ||
| 326 | { env: { log: () => {} } }, | ||
| 327 | ); | ||
| 328 | const instance = result instanceof WebAssembly.Instance ? result : result.instance; | ||
| 329 | const memory = instance.exports.memory as WebAssembly.Memory; | ||
| 330 | const call = (name: string, ...args: number[]) => | ||
| 331 | (instance.exports[name] as WasmFunction)(...args); | ||
| 332 | const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size); | ||
| 333 | const free = (pointer: number, size: number) => | ||
| 334 | call("ghostty_wasm_free_u8_array", pointer, size); | ||
| 335 | const options = alloc(8); | ||
| 336 | new DataView(memory.buffer, options, 8).setUint16(0, 80, true); | ||
| 337 | new DataView(memory.buffer, options, 8).setUint16(2, 24, true); | ||
| 338 | const terminalSlot = call("ghostty_wasm_alloc_opaque"); | ||
| 339 | expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); | ||
| 340 | const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); | ||
| 341 | const input = new TextEncoder().encode("hello\r\nworld"); | ||
| 342 | const inputPointer = alloc(input.length); | ||
| 343 | new Uint8Array(memory.buffer, inputPointer, input.length).set(input); | ||
| 344 | call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); | ||
| 345 | |||
| 346 | const gridRefAt = (x: number, y: number) => { | ||
| 347 | const point = alloc(24); | ||
| 348 | const pointView = new DataView(memory.buffer, point, 24); | ||
| 349 | pointView.setUint32(0, 2, true); | ||
| 350 | pointView.setUint16(8, x, true); | ||
| 351 | pointView.setUint32(12, y, true); | ||
| 352 | const ref = alloc(12); | ||
| 353 | new DataView(memory.buffer, ref, 12).setUint32(0, 12, true); | ||
| 354 | expect(call("ghostty_terminal_grid_ref", terminal, point, ref)).toBe(0); | ||
| 355 | free(point, 24); | ||
| 356 | return ref; | ||
| 357 | }; | ||
| 358 | const start = gridRefAt(0, 0); | ||
| 359 | const end = gridRefAt(4, 1); | ||
| 360 | const selection = alloc(32); | ||
| 361 | const selectionBytes = new Uint8Array(memory.buffer, selection, 32); | ||
| 362 | selectionBytes.fill(0); | ||
| 363 | new DataView(memory.buffer, selection, 32).setUint32(0, 32, true); | ||
| 364 | selectionBytes.set(new Uint8Array(memory.buffer, start, 12), 4); | ||
| 365 | selectionBytes.set(new Uint8Array(memory.buffer, end, 12), 16); | ||
| 366 | expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0); | ||
| 367 | |||
| 368 | const formatOptions = alloc(16); | ||
| 369 | new Uint8Array(memory.buffer, formatOptions, 16).fill(0); | ||
| 370 | const formatView = new DataView(memory.buffer, formatOptions, 16); | ||
| 371 | formatView.setUint32(0, 16, true); | ||
| 372 | formatView.setUint8(8, 1); | ||
| 373 | formatView.setUint8(9, 1); | ||
| 374 | const written = call("ghostty_wasm_alloc_usize"); | ||
| 375 | expect( | ||
| 376 | call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written), | ||
| 377 | ).toBe(-3); | ||
| 378 | const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); | ||
| 379 | const output = alloc(outputSize); | ||
| 380 | expect( | ||
| 381 | call( | ||
| 382 | "ghostty_terminal_selection_format_buf", | ||
| 383 | terminal, | ||
| 384 | formatOptions, | ||
| 385 | output, | ||
| 386 | outputSize, | ||
| 387 | written, | ||
| 388 | ), | ||
| 389 | ).toBe(0); | ||
| 390 | expect(new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputSize))).toBe( | ||
| 391 | "hello\nworld", | ||
| 392 | ); | ||
| 393 | |||
| 394 | free(output, outputSize); | ||
| 395 | call("ghostty_wasm_free_usize", written); | ||
| 396 | free(formatOptions, 16); | ||
| 397 | free(selection, 32); | ||
| 398 | free(end, 12); | ||
| 399 | free(start, 12); | ||
| 400 | free(inputPointer, input.length); | ||
| 401 | call("ghostty_terminal_free", terminal); | ||
| 402 | call("ghostty_wasm_free_opaque", terminalSlot); | ||
| 403 | free(options, 8); | ||
| 404 | }); | ||
| 405 | |||
| 323 | it("uses Ghostty for mouse encoding, word selection, and OSC 8 hit testing", async () => { | 406 | it("uses Ghostty for mouse encoding, word selection, and OSC 8 hit testing", async () => { |
| 324 | const result = await WebAssembly.instantiate( | 407 | const result = await WebAssembly.instantiate( |
| 325 | decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, | 408 | decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, |
apps/web/src/terminal/ghostty/surface.test.ts+99| ... | @@ -5,13 +5,17 @@ import { | ... | @@ -5,13 +5,17 @@ import { |
| 5 | DEFAULT_TERMINAL_FONT_FAMILY, | 5 | DEFAULT_TERMINAL_FONT_FAMILY, |
| 6 | DEFAULT_TERMINAL_FONT_SIZE, | 6 | DEFAULT_TERMINAL_FONT_SIZE, |
| 7 | advanceTerminalSelectionClickSequence, | 7 | advanceTerminalSelectionClickSequence, |
| 8 | applyTerminalCopyEvent, | ||
| 9 | clearPrimedTerminalCopyInput, | ||
| 8 | ghosttyMouseButton, | 10 | ghosttyMouseButton, |
| 9 | isTerminalAltGraphText, | 11 | isTerminalAltGraphText, |
| 10 | isTerminalCompositionCommitInput, | 12 | isTerminalCompositionCommitInput, |
| 13 | isTerminalCompositionKey, | ||
| 11 | isTerminalCopyShortcut, | 14 | isTerminalCopyShortcut, |
| 12 | isTerminalLinkPointerGesture, | 15 | isTerminalLinkPointerGesture, |
| 13 | isTerminalPasteShortcut, | 16 | isTerminalPasteShortcut, |
| 14 | loadTerminalFontFamily, | 17 | loadTerminalFontFamily, |
| 18 | primeTerminalCopyInput, | ||
| 15 | shouldBlinkTerminalCursor, | 19 | shouldBlinkTerminalCursor, |
| 16 | shouldReportTerminalMouse, | 20 | shouldReportTerminalMouse, |
| 17 | shouldShowTerminalLinkHover, | 21 | shouldShowTerminalLinkHover, |
| ... | @@ -233,6 +237,79 @@ describe("isTerminalCopyShortcut", () => { | ... | @@ -233,6 +237,79 @@ describe("isTerminalCopyShortcut", () => { |
| 233 | }); | 237 | }); |
| 234 | }); | 238 | }); |
| 235 | 239 | ||
| 240 | describe("applyTerminalCopyEvent", () => { | ||
| 241 | it("writes the selection and claims the fallback when clipboardData is present", () => { | ||
| 242 | const setData = vi.fn(); | ||
| 243 | expect(applyTerminalCopyEvent("ls -la", { setData })).toEqual({ | ||
| 244 | preventDefault: true, | ||
| 245 | claimWriteFallback: true, | ||
| 246 | }); | ||
| 247 | expect(setData).toHaveBeenCalledWith("text/plain", "ls -la"); | ||
| 248 | }); | ||
| 249 | |||
| 250 | it("leaves the writeText fallback alive when clipboardData is missing", () => { | ||
| 251 | // Electron's edit-menu Copy often delivers a copy event with no | ||
| 252 | // clipboardData. Claiming that event used to skip writeText and copy the | ||
| 253 | // empty IME textarea, which is the blank clipboard users paste. | ||
| 254 | expect(applyTerminalCopyEvent("ls -la", null)).toEqual({ | ||
| 255 | preventDefault: false, | ||
| 256 | claimWriteFallback: false, | ||
| 257 | }); | ||
| 258 | expect(applyTerminalCopyEvent("", { setData: vi.fn() })).toEqual({ | ||
| 259 | preventDefault: false, | ||
| 260 | claimWriteFallback: false, | ||
| 261 | }); | ||
| 262 | }); | ||
| 263 | |||
| 264 | it("primes the current selection before a copy event with no clipboardData", () => { | ||
| 265 | const input = { | ||
| 266 | value: "stale", | ||
| 267 | selectionStart: 0, | ||
| 268 | selectionEnd: 0, | ||
| 269 | select() { | ||
| 270 | this.selectionStart = 0; | ||
| 271 | this.selectionEnd = this.value.length; | ||
| 272 | }, | ||
| 273 | }; | ||
| 274 | primeTerminalCopyInput(input, "git status"); | ||
| 275 | expect(applyTerminalCopyEvent("git status", null)).toEqual({ | ||
| 276 | preventDefault: false, | ||
| 277 | claimWriteFallback: false, | ||
| 278 | }); | ||
| 279 | expect(input.value).toBe("git status"); | ||
| 280 | expect(input.selectionStart).toBe(0); | ||
| 281 | expect(input.selectionEnd).toBe(10); | ||
| 282 | }); | ||
| 283 | }); | ||
| 284 | |||
| 285 | describe("primeTerminalCopyInput", () => { | ||
| 286 | it("selects the Ghostty selection in the hidden textarea so native copy has text", () => { | ||
| 287 | const input = { | ||
| 288 | value: "", | ||
| 289 | selectionStart: 0, | ||
| 290 | selectionEnd: 0, | ||
| 291 | select() { | ||
| 292 | this.selectionStart = 0; | ||
| 293 | this.selectionEnd = this.value.length; | ||
| 294 | }, | ||
| 295 | }; | ||
| 296 | primeTerminalCopyInput(input, "git status"); | ||
| 297 | expect(input.value).toBe("git status"); | ||
| 298 | expect(input.selectionStart).toBe(0); | ||
| 299 | expect(input.selectionEnd).toBe(10); | ||
| 300 | clearPrimedTerminalCopyInput(input, "git status"); | ||
| 301 | expect(input.value).toBe(""); | ||
| 302 | }); | ||
| 303 | |||
| 304 | it("does not wipe an IME candidate that replaced the primed copy", () => { | ||
| 305 | const input = { value: "", select() {} }; | ||
| 306 | primeTerminalCopyInput(input, "git status"); | ||
| 307 | input.value = "あ"; | ||
| 308 | clearPrimedTerminalCopyInput(input, "git status"); | ||
| 309 | expect(input.value).toBe("あ"); | ||
| 310 | }); | ||
| 311 | }); | ||
| 312 | |||
| 236 | describe("isTerminalPasteShortcut", () => { | 313 | describe("isTerminalPasteShortcut", () => { |
| 237 | const event = (overrides: Partial<Parameters<typeof isTerminalPasteShortcut>[0]> = {}) => ({ | 314 | const event = (overrides: Partial<Parameters<typeof isTerminalPasteShortcut>[0]> = {}) => ({ |
| 238 | ctrlKey: false, | 315 | ctrlKey: false, |
| ... | @@ -283,6 +360,28 @@ describe("isTerminalCompositionCommitInput", () => { | ... | @@ -283,6 +360,28 @@ describe("isTerminalCompositionCommitInput", () => { |
| 283 | }); | 360 | }); |
| 284 | }); | 361 | }); |
| 285 | 362 | ||
| 363 | describe("isTerminalCompositionKey", () => { | ||
| 364 | const event = ( | ||
| 365 | overrides: Partial<Pick<KeyboardEvent, "isComposing" | "key" | "keyCode">> = {}, | ||
| 366 | ) => ({ | ||
| 367 | isComposing: false, | ||
| 368 | key: "a", | ||
| 369 | keyCode: 65, | ||
| 370 | ...overrides, | ||
| 371 | }); | ||
| 372 | |||
| 373 | it("treats in-progress IME keydowns as composition so the copy primer cannot wipe them", () => { | ||
| 374 | expect(isTerminalCompositionKey(event({ isComposing: true }), false)).toBe(true); | ||
| 375 | expect(isTerminalCompositionKey(event(), true)).toBe(true); | ||
| 376 | expect(isTerminalCompositionKey(event({ key: "Process" }), false)).toBe(true); | ||
| 377 | expect(isTerminalCompositionKey(event({ keyCode: 229 }), false)).toBe(true); | ||
| 378 | }); | ||
| 379 | |||
| 380 | it("lets ordinary keydowns clear a primed copy", () => { | ||
| 381 | expect(isTerminalCompositionKey(event(), false)).toBe(false); | ||
| 382 | }); | ||
| 383 | }); | ||
| 384 | |||
| 286 | describe("application mouse reporting", () => { | 385 | describe("application mouse reporting", () => { |
| 287 | const event = (overrides: Partial<Parameters<typeof shouldReportTerminalMouse>[1]> = {}) => ({ | 386 | const event = (overrides: Partial<Parameters<typeof shouldReportTerminalMouse>[1]> = {}) => ({ |
| 288 | ctrlKey: false, | 387 | ctrlKey: false, |
apps/web/src/terminal/ghostty/surface.ts+87-16| ... | @@ -336,6 +336,47 @@ export function isTerminalCopyShortcut( | ... | @@ -336,6 +336,47 @@ export function isTerminalCopyShortcut( |
| 336 | return isMacPlatform(platform) ? event.metaKey : event.ctrlKey; | 336 | return isMacPlatform(platform) ? event.metaKey : event.ctrlKey; |
| 337 | } | 337 | } |
| 338 | 338 | ||
| 339 | /** | ||
| 340 | * Canvas terminals have no DOM selection. Native copy and Electron's Edit | ||
| 341 | * menu `role: "copy"` both read the focused textarea, so an empty IME field | ||
| 342 | * writes blankness to the clipboard. Park the Ghostty selection there first. | ||
| 343 | */ | ||
| 344 | export function primeTerminalCopyInput( | ||
| 345 | input: Pick<HTMLTextAreaElement, "value" | "select">, | ||
| 346 | selection: string, | ||
| 347 | ): void { | ||
| 348 | input.value = selection; | ||
| 349 | if (selection.length === 0) return; | ||
| 350 | input.select(); | ||
| 351 | } | ||
| 352 | |||
| 353 | export function clearPrimedTerminalCopyInput( | ||
| 354 | input: Pick<HTMLTextAreaElement, "value">, | ||
| 355 | primedSelection: string, | ||
| 356 | ): void { | ||
| 357 | // Only blank the copy we parked. The same textarea holds the IME candidate; | ||
| 358 | // wiping whatever is there would cancel CJK composition. | ||
| 359 | if (primedSelection.length === 0 || input.value !== primedSelection) return; | ||
| 360 | input.value = ""; | ||
| 361 | } | ||
| 362 | |||
| 363 | /** | ||
| 364 | * Only a copy event that actually received the selection may cancel the | ||
| 365 | * clipboard.writeText fallback. Claiming without clipboardData (Electron's | ||
| 366 | * menu Copy) used to preventDefault an empty write and skip the fallback, | ||
| 367 | * which is how Cmd+C copied blankness. | ||
| 368 | */ | ||
| 369 | export function applyTerminalCopyEvent( | ||
| 370 | selection: string, | ||
| 371 | clipboardData: { setData: (type: string, data: string) => void } | null | undefined, | ||
| 372 | ): { preventDefault: boolean; claimWriteFallback: boolean } { | ||
| 373 | if (selection.length === 0 || !clipboardData) { | ||
| 374 | return { preventDefault: false, claimWriteFallback: false }; | ||
| 375 | } | ||
| 376 | clipboardData.setData("text/plain", selection); | ||
| 377 | return { preventDefault: true, claimWriteFallback: true }; | ||
| 378 | } | ||
| 379 | |||
| 339 | export function isTerminalPasteShortcut( | 380 | export function isTerminalPasteShortcut( |
| 340 | event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">, | 381 | event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">, |
| 341 | platform = navigator.platform, | 382 | platform = navigator.platform, |
| ... | @@ -356,6 +397,14 @@ export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputT | ... | @@ -356,6 +397,14 @@ export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputT |
| 356 | ); | 397 | ); |
| 357 | } | 398 | } |
| 358 | 399 | ||
| 400 | /** IME keydowns must not touch the hidden textarea; it holds the candidate. */ | ||
| 401 | export function isTerminalCompositionKey( | ||
| 402 | event: Pick<KeyboardEvent, "isComposing" | "key" | "keyCode">, | ||
| 403 | composing: boolean, | ||
| 404 | ): boolean { | ||
| 405 | return event.isComposing || composing || event.key === "Process" || event.keyCode === 229; | ||
| 406 | } | ||
| 407 | |||
| 359 | export function isTerminalAltGraphText( | 408 | export function isTerminalAltGraphText( |
| 360 | event: Pick<KeyboardEvent, "getModifierState" | "key">, | 409 | event: Pick<KeyboardEvent, "getModifierState" | "key">, |
| 361 | ): boolean { | 410 | ): boolean { |
| ... | @@ -542,6 +591,7 @@ export class GhosttyTerminalSurface { | ... | @@ -542,6 +591,7 @@ export class GhosttyTerminalSurface { |
| 542 | private pasteShortcutToken = 0; | 591 | private pasteShortcutToken = 0; |
| 543 | private copyShortcutToken = 0; | 592 | private copyShortcutToken = 0; |
| 544 | private clearSelectionAfterCopy = false; | 593 | private clearSelectionAfterCopy = false; |
| 594 | private primedCopySelection = ""; | ||
| 545 | private wheelRemainder = 0; | 595 | private wheelRemainder = 0; |
| 546 | private dprMedia: MediaQueryList | null = null; | 596 | private dprMedia: MediaQueryList | null = null; |
| 547 | // Read live on every blink decision, and watched so that dropping the | 597 | // Read live on every blink decision, and watched so that dropping the |
| ... | @@ -866,6 +916,7 @@ export class GhosttyTerminalSurface { | ... | @@ -866,6 +916,7 @@ export class GhosttyTerminalSurface { |
| 866 | } | 916 | } |
| 867 | 917 | ||
| 868 | clearSelection(): void { | 918 | clearSelection(): void { |
| 919 | this.clearPrimedCopy(); | ||
| 869 | this.core.clearSelection(); | 920 | this.core.clearSelection(); |
| 870 | this.selectionEnd = null; | 921 | this.selectionEnd = null; |
| 871 | this.selectionAnchorScreen = null; | 922 | this.selectionAnchorScreen = null; |
| ... | @@ -941,6 +992,8 @@ export class GhosttyTerminalSurface { | ... | @@ -941,6 +992,8 @@ export class GhosttyTerminalSurface { |
| 941 | // clipboard write against it the same way paste races its read. The | 992 | // clipboard write against it the same way paste races its read. The |
| 942 | // Shift variant has no native event (Chrome binds Ctrl+Shift+C to | 993 | // Shift variant has no native event (Chrome binds Ctrl+Shift+C to |
| 943 | // inspect), so synthesize one with execCommand("copy"). | 994 | // inspect), so synthesize one with execCommand("copy"). |
| 995 | const selection = this.getSelection(); | ||
| 996 | this.primeCopy(selection); | ||
| 944 | if (event.shiftKey) { | 997 | if (event.shiftKey) { |
| 945 | event.preventDefault(); | 998 | event.preventDefault(); |
| 946 | document.execCommand("copy"); | 999 | document.execCommand("copy"); |
| ... | @@ -955,12 +1008,10 @@ export class GhosttyTerminalSurface { | ... | @@ -955,12 +1008,10 @@ export class GhosttyTerminalSurface { |
| 955 | if (typeof clipboard?.writeText === "function") { | 1008 | if (typeof clipboard?.writeText === "function") { |
| 956 | // Defer the write past the default action: the native copy event | 1009 | // Defer the write past the default action: the native copy event |
| 957 | // (dispatched synchronously with the default action) claims the | 1010 | // (dispatched synchronously with the default action) claims the |
| 958 | // token first when it fires, and the write covers browsers whose | 1011 | // token first when it actually writes, and the write covers browsers |
| 959 | // shortcut produces no copy event. Skipping a write the native | 1012 | // whose shortcut produces no copy event. The primed textarea is what |
| 960 | // event already handled stops a stale resolution from clobbering a | 1013 | // Electron's edit-menu Copy reads if it runs after this handler. |
| 961 | // clipboard the user filled after this copy. | ||
| 962 | const token = ++this.copyShortcutToken; | 1014 | const token = ++this.copyShortcutToken; |
| 963 | const selection = this.getSelection(); | ||
| 964 | void Promise.resolve().then(() => { | 1015 | void Promise.resolve().then(() => { |
| 965 | if (this.disposed || this.copyShortcutToken !== token) return; | 1016 | if (this.disposed || this.copyShortcutToken !== token) return; |
| 966 | void clipboard.writeText(selection).then( | 1017 | void clipboard.writeText(selection).then( |
| ... | @@ -1013,10 +1064,12 @@ export class GhosttyTerminalSurface { | ... | @@ -1013,10 +1064,12 @@ export class GhosttyTerminalSurface { |
| 1013 | return; | 1064 | return; |
| 1014 | } | 1065 | } |
| 1015 | // keyCode 229 is Safari's only signal that this keydown opens an IME | 1066 | // keyCode 229 is Safari's only signal that this keydown opens an IME |
| 1016 | // composition; encoding it would double the committed text. | 1067 | // composition; encoding it would double the committed text. Do not blank |
| 1017 | if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) { | 1068 | // the textarea first: onInput leaves the in-progress candidate there. |
| 1069 | if (isTerminalCompositionKey(event, this.composing)) { | ||
| 1018 | return; | 1070 | return; |
| 1019 | } | 1071 | } |
| 1072 | this.clearPrimedCopy(); | ||
| 1020 | const data = this.core.encodeKey(event); | 1073 | const data = this.core.encodeKey(event); |
| 1021 | if (data.length === 0) return; | 1074 | if (data.length === 0) return; |
| 1022 | this.suppressedKeyCodes.delete(event.code); | 1075 | this.suppressedKeyCodes.delete(event.code); |
| ... | @@ -1028,7 +1081,7 @@ export class GhosttyTerminalSurface { | ... | @@ -1028,7 +1081,7 @@ export class GhosttyTerminalSurface { |
| 1028 | private readonly onKeyUp = (event: KeyboardEvent) => { | 1081 | private readonly onKeyUp = (event: KeyboardEvent) => { |
| 1029 | this.updateLinkModifier(event); | 1082 | this.updateLinkModifier(event); |
| 1030 | if (this.suppressedKeyCodes.delete(event.code)) return; | 1083 | if (this.suppressedKeyCodes.delete(event.code)) return; |
| 1031 | if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) { | 1084 | if (isTerminalCompositionKey(event, this.composing)) { |
| 1032 | return; | 1085 | return; |
| 1033 | } | 1086 | } |
| 1034 | // Ghostty's encoder only emits release codes when the terminal enabled the | 1087 | // Ghostty's encoder only emits release codes when the terminal enabled the |
| ... | @@ -1072,15 +1125,32 @@ export class GhosttyTerminalSurface { | ... | @@ -1072,15 +1125,32 @@ export class GhosttyTerminalSurface { |
| 1072 | this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange); | 1125 | this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange); |
| 1073 | } | 1126 | } |
| 1074 | 1127 | ||
| 1128 | private primeCopy(selection: string): void { | ||
| 1129 | this.primedCopySelection = selection; | ||
| 1130 | primeTerminalCopyInput(this.input, selection); | ||
| 1131 | } | ||
| 1132 | |||
| 1133 | private clearPrimedCopy(): void { | ||
| 1134 | clearPrimedTerminalCopyInput(this.input, this.primedCopySelection); | ||
| 1135 | this.primedCopySelection = ""; | ||
| 1136 | } | ||
| 1137 | |||
| 1075 | private readonly onCopyEvent = (event: ClipboardEvent) => { | 1138 | private readonly onCopyEvent = (event: ClipboardEvent) => { |
| 1076 | if (!this.hasSelection()) return; | 1139 | const selection = this.hasSelection() ? this.getSelection() : this.input.value; |
| 1077 | event.preventDefault(); | 1140 | // Menu-role Copy never hits the keydown primer. The native action reads |
| 1078 | event.clipboardData?.setData("text/plain", this.getSelection()); | 1141 | // this.input, so park the current selection first — including when |
| 1079 | // The native event beat any deferred write; drop the in-flight fallback. | 1142 | // clipboardData is missing and we must not preventDefault. |
| 1080 | this.copyShortcutToken += 1; | 1143 | this.primeCopy(selection); |
| 1081 | if (this.clearSelectionAfterCopy) { | 1144 | const result = applyTerminalCopyEvent(selection, event.clipboardData); |
| 1082 | this.clearSelectionAfterCopy = false; | 1145 | if (result.preventDefault) event.preventDefault(); |
| 1083 | this.clearSelection(); | 1146 | if (result.claimWriteFallback) { |
| 1147 | // The native event actually wrote the selection; drop the in-flight | ||
| 1148 | // writeText so a late resolution cannot clobber a later user copy. | ||
| 1149 | this.copyShortcutToken += 1; | ||
| 1150 | if (this.clearSelectionAfterCopy) { | ||
| 1151 | this.clearSelectionAfterCopy = false; | ||
| 1152 | this.clearSelection(); | ||
| 1153 | } | ||
| 1084 | } | 1154 | } |
| 1085 | }; | 1155 | }; |
| 1086 | 1156 | ||
| ... | @@ -1098,6 +1168,7 @@ export class GhosttyTerminalSurface { | ... | @@ -1098,6 +1168,7 @@ export class GhosttyTerminalSurface { |
| 1098 | }; | 1168 | }; |
| 1099 | 1169 | ||
| 1100 | private readonly onCompositionStart = () => { | 1170 | private readonly onCompositionStart = () => { |
| 1171 | this.clearPrimedCopy(); | ||
| 1101 | this.clearCompositionInputSuppression(); | 1172 | this.clearCompositionInputSuppression(); |
| 1102 | this.composing = true; | 1173 | this.composing = true; |
| 1103 | }; | 1174 | }; |