d0c694a2
feat: copy mouse selections in muxweb
a73x 2026-08-18 12:27
Commit message
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -132,8 +132,10 @@ class Tile { | |||
| 132 | this.clipboardVersion = 0; | 132 | this.clipboardVersion = 0; |
| 133 | this.clipboardWriteActive = false; | 133 | this.clipboardWriteActive = false; |
| 134 | this.copyUiOwner = null; // null, 'clipboard', or 'selection' | 134 | this.copyUiOwner = null; // null, 'clipboard', or 'selection' |
| 135 | this.selectionCopyVersion = 0; | ||
| 135 | this.viewStartRow = 0; | 136 | this.viewStartRow = 0; |
| 136 | this.requestedViewStartRow = 0; | 137 | this.requestedViewStartRow = 0; |
| 138 | this.scrollRequest = null; // {fromPages, toPages, start}; at most one in flight | ||
| 137 | this.selection = null; // {anchor, active, requestId, text} | 139 | this.selection = null; // {anchor, active, requestId, text} |
| 138 | this.nextSelectionId = 0; | 140 | this.nextSelectionId = 0; |
| 139 | this.drag = null; | 141 | this.drag = null; |
| @@ -149,6 +151,7 @@ class Tile { | |||
| 149 | this.copyButton.addEventListener('click', (ev) => { | 151 | this.copyButton.addEventListener('click', (ev) => { |
| 150 | ev.stopPropagation(); | 152 | ev.stopPropagation(); |
| 151 | if (this.copyUiOwner === 'clipboard') this.copyPendingClipboard(); | 153 | if (this.copyUiOwner === 'clipboard') this.copyPendingClipboard(); |
| 154 | else if (this.copyUiOwner === 'selection') this.copySelection(); | ||
| 152 | ime.focus(); | 155 | ime.focus(); |
| 153 | }); | 156 | }); |
| 154 | this.canvas = document.createElement('canvas'); | 157 | this.canvas = document.createElement('canvas'); |
| @@ -260,6 +263,8 @@ class Tile { | |||
| 260 | // with a snapshot. | 263 | // with a snapshot. |
| 261 | resetCore(why) { | 264 | resetCore(why) { |
| 262 | this.clearSelection(false); | 265 | this.clearSelection(false); |
| 266 | this.scrollRequest = null; | ||
| 267 | this.scrollPages = 0; | ||
| 263 | if (this.core.mux_init(80, 24) !== 0) { | 268 | if (this.core.mux_init(80, 24) !== 0) { |
| 264 | this.clearBackingCanvas(); | 269 | this.clearBackingCanvas(); |
| 265 | this.replayDead = true; | 270 | this.replayDead = true; |
| @@ -267,7 +272,6 @@ class Tile { | |||
| 267 | return; | 272 | return; |
| 268 | } | 273 | } |
| 269 | this.gotState = false; | 274 | this.gotState = false; |
| 270 | this.scrollPages = 0; | ||
| 271 | this.renderBadge(); // scrollPages changed; the mask must lift with it | 275 | this.renderBadge(); // scrollPages changed; the mask must lift with it |
| 272 | this.drawScale = 0; // force the backing store to be re-sized | 276 | this.drawScale = 0; // force the backing store to be re-sized |
| 273 | this.reflow(); // paint only after mux_init made the replacement core valid | 277 | this.reflow(); // paint only after mux_init made the replacement core valid |
| @@ -330,6 +334,7 @@ class Tile { | |||
| 330 | } | 334 | } |
| 331 | } | 335 | } |
| 332 | sendKey(keyId, cp, mods) { | 336 | sendKey(keyId, cp, mods) { |
| 337 | this.clearSelection(); | ||
| 333 | const n = this.core.mux_key_encode(keyId, cp, mods); | 338 | const n = this.core.mux_key_encode(keyId, cp, mods); |
| 334 | if (n > 0) this.sendFrame(MSG.input, this.outBytes()); | 339 | if (n > 0) this.sendFrame(MSG.input, this.outBytes()); |
| 335 | } | 340 | } |
| @@ -339,6 +344,7 @@ class Tile { | |||
| 339 | // application — vim would skip paste mode's indentation, a shell's | 344 | // application — vim would skip paste mode's indentation, a shell's |
| 340 | // bracketed-paste guard would refuse to run it. | 345 | // bracketed-paste guard would refuse to run it. |
| 341 | sendText(text) { | 346 | sendText(text) { |
| 347 | this.clearSelection(); | ||
| 342 | const bytes = new TextEncoder().encode(text); | 348 | const bytes = new TextEncoder().encode(text); |
| 343 | for (let off = 0; off < bytes.length; off += PASTE_CHUNK) { | 349 | for (let off = 0; off < bytes.length; off += PASTE_CHUNK) { |
| 344 | const chunk = bytes.subarray(off, off + PASTE_CHUNK); | 350 | const chunk = bytes.subarray(off, off + PASTE_CHUNK); |
| @@ -457,16 +463,28 @@ class Tile { | |||
| 457 | return; | 463 | return; |
| 458 | } | 464 | } |
| 459 | case MSG.scrollback_chunk: { | 465 | case MSG.scrollback_chunk: { |
| 460 | if (this.scrollPages === 0 || payload.length < 6) return; | 466 | if (this.scrollPages === 0) return; |
| 467 | if (payload.length < 6) { | ||
| 468 | if (this.scrollRequest) this.scrollRequestFailed(this.scrollRequest); | ||
| 469 | return; | ||
| 470 | } | ||
| 461 | const start = new DataView( | 471 | const start = new DataView( |
| 462 | payload.buffer, payload.byteOffset, 6, | 472 | payload.buffer, payload.byteOffset, 6, |
| 463 | ).getUint32(0, true); | 473 | ).getUint32(0, true); |
| 474 | // A viewport becomes addressable by pointer coordinates only after | ||
| 475 | // the exact request for it decoded and painted. Delayed older wheel | ||
| 476 | // replies must never relabel the cells currently on the canvas. | ||
| 477 | const request = this.scrollRequest; | ||
| 478 | if (!request || start !== request.start) return; | ||
| 464 | const rows = payload.subarray(6); // echoed start+count stripped | 479 | const rows = payload.subarray(6); // echoed start+count stripped |
| 465 | if (!this.stage(rows)) return; | 480 | if (!this.stage(rows)) { this.scrollRequestFailed(request); return; } |
| 466 | if (this.core.mux_scroll_feed(rows.length) === 0) { | 481 | if (this.core.mux_scroll_feed(rows.length) === 0) { |
| 482 | this.scrollRequest = null; | ||
| 467 | this.viewStartRow = start; | 483 | this.viewStartRow = start; |
| 484 | this.requestedViewStartRow = start; | ||
| 485 | this.moveDragToScrollBoundary(); | ||
| 468 | this.paintScroll(); | 486 | this.paintScroll(); |
| 469 | } | 487 | } else this.scrollRequestFailed(request); |
| 470 | return; | 488 | return; |
| 471 | } | 489 | } |
| 472 | case MSG.term_modes: | 490 | case MSG.term_modes: |
| @@ -528,9 +546,7 @@ class Tile { | |||
| 528 | this.clipboardWriteActive = true; | 546 | this.clipboardWriteActive = true; |
| 529 | let succeeded = false; | 547 | let succeeded = false; |
| 530 | try { | 548 | try { |
| 531 | const writeText = navigator.clipboard?.writeText; | 549 | await this.writeClipboardText(text); |
| 532 | if (typeof writeText !== 'function') throw new Error('clipboard unavailable'); | ||
| 533 | await writeText.call(navigator.clipboard, text); | ||
| 534 | succeeded = true; | 550 | succeeded = true; |
| 535 | } catch (_) { | 551 | } catch (_) { |
| 536 | // Expected platform failures are rendered below, never allowed to | 552 | // Expected platform failures are rendered below, never allowed to |
| @@ -572,6 +588,41 @@ class Tile { | |||
| 572 | return this.tryClipboardWrite(this.clipboardVersion, false); | 588 | return this.tryClipboardWrite(this.clipboardVersion, false); |
| 573 | } | 589 | } |
| 574 | 590 | ||
| 591 | async writeClipboardText(text) { | ||
| 592 | const writeText = navigator.clipboard?.writeText; | ||
| 593 | if (typeof writeText !== 'function') throw new Error('clipboard unavailable'); | ||
| 594 | await writeText.call(navigator.clipboard, text); | ||
| 595 | } | ||
| 596 | |||
| 597 | async copySelection() { | ||
| 598 | const selection = this.selection; | ||
| 599 | if (!this.zoomed || !selection || selection.text === null) return; | ||
| 600 | const version = ++this.selectionCopyVersion; | ||
| 601 | this.renderCopyUi('selection', 'copy-request', 'Copy'); | ||
| 602 | let succeeded = false; | ||
| 603 | try { | ||
| 604 | // This call occurs synchronously before the first await, preserving | ||
| 605 | // the key/click user activation required by the Clipboard API. | ||
| 606 | await this.writeClipboardText(selection.text); | ||
| 607 | succeeded = true; | ||
| 608 | } catch (_) { | ||
| 609 | // Clipboard refusal is visible browser UI, never an unhandled event | ||
| 610 | // promise rejection. | ||
| 611 | } | ||
| 612 | if (!this.zoomed || this.selection !== selection || | ||
| 613 | version !== this.selectionCopyVersion || this.copyUiOwner !== 'selection') return; | ||
| 614 | if (!succeeded) { | ||
| 615 | this.renderCopyUi('selection', 'copy-request on error', 'Copy failed'); | ||
| 616 | return; | ||
| 617 | } | ||
| 618 | this.renderCopyUi('selection', 'copy-request on', 'Copied'); | ||
| 619 | setTimeout(() => { | ||
| 620 | if (!this.zoomed || this.selection !== selection || | ||
| 621 | version !== this.selectionCopyVersion || this.copyUiOwner !== 'selection') return; | ||
| 622 | this.releaseSelectionCopyUi(); | ||
| 623 | }, 1200); | ||
| 624 | } | ||
| 625 | |||
| 575 | onSelectionReply() { | 626 | onSelectionReply() { |
| 576 | if (!this.selection) return; | 627 | if (!this.selection) return; |
| 577 | // A Zig u32 result reaches JavaScript as a signed WASM i32. | 628 | // A Zig u32 result reaches JavaScript as a signed WASM i32. |
| @@ -615,6 +666,7 @@ class Tile { | |||
| 615 | 666 | ||
| 616 | clearSelection(repaint = true) { | 667 | clearSelection(repaint = true) { |
| 617 | const hadSelection = this.selection !== null; | 668 | const hadSelection = this.selection !== null; |
| 669 | if (hadSelection) this.selectionCopyVersion++; | ||
| 618 | this.selection = null; | 670 | this.selection = null; |
| 619 | this.stopSelectionDrag(); | 671 | this.stopSelectionDrag(); |
| 620 | this.releaseSelectionCopyUi(); | 672 | this.releaseSelectionCopyUi(); |
| @@ -674,10 +726,37 @@ class Tile { | |||
| 674 | else this.clearSelection(); | 726 | else this.clearSelection(); |
| 675 | } | 727 | } |
| 676 | 728 | ||
| 677 | // Task 6 gives this timer its scroll policy. Keeping the callback valid | 729 | pointerOutsideDirection() { |
| 678 | // here makes the pointer lifecycle complete without changing viewport | 730 | if (!this.drag || this.lastPointerY === null) return 0; |
| 679 | // behavior in this slice. | 731 | const rect = this.canvas.getBoundingClientRect(); |
| 680 | autoScrollSelection() {} | 732 | return this.lastPointerY < rect.top ? 1 : |
| 733 | (this.lastPointerY >= rect.bottom ? -1 : 0); | ||
| 734 | } | ||
| 735 | |||
| 736 | moveDragToScrollBoundary() { | ||
| 737 | const direction = this.pointerOutsideDirection(); | ||
| 738 | if (direction === 0 || !this.selection) return false; | ||
| 739 | const viewRow = direction > 0 ? 0 : this.core.mux_rows() - 1; | ||
| 740 | this.selection.active = { | ||
| 741 | row: this.viewStartRow + viewRow, | ||
| 742 | col: this.selection.active.col, | ||
| 743 | viewRow, | ||
| 744 | }; | ||
| 745 | this.drag.moved = true; | ||
| 746 | return true; | ||
| 747 | } | ||
| 748 | |||
| 749 | autoScrollSelection() { | ||
| 750 | const direction = this.pointerOutsideDirection(); | ||
| 751 | if (direction === 0 || this.scrollRequest) return; | ||
| 752 | const paintedStart = this.viewStartRow; | ||
| 753 | if (!this.changeScrollPages(direction)) return; | ||
| 754 | // Returning to the live viewport paints synchronously in exitScroll. | ||
| 755 | // History requests update the endpoint only in scrollback_chunk after | ||
| 756 | // their matching bytes have decoded and become the painted viewport. | ||
| 757 | if (!this.scrollRequest && this.viewStartRow !== paintedStart && | ||
| 758 | this.moveDragToScrollBoundary()) this.reflow(); | ||
| 759 | } | ||
| 681 | 760 | ||
| 682 | // --- painting --- | 761 | // --- painting --- |
| 683 | clearBackingCanvas() { | 762 | clearBackingCanvas() { |
| @@ -833,34 +912,42 @@ class Tile { | |||
| 833 | } | 912 | } |
| 834 | 913 | ||
| 835 | // --- scrollback --- | 914 | // --- scrollback --- |
| 836 | onWheel(ev) { | 915 | changeScrollPages(pagesUpDelta) { |
| 916 | if (this.scrollRequest) return false; | ||
| 837 | const rows = this.core.mux_rows(); | 917 | const rows = this.core.mux_rows(); |
| 838 | const hist = this.core.mux_history_rows(); | 918 | const maxPages = Math.ceil(this.core.mux_history_rows() / rows); |
| 839 | if (ev.deltaY < 0) { | 919 | const next = Math.max(0, Math.min(maxPages, this.scrollPages + pagesUpDelta)); |
| 840 | const maxPages = Math.ceil(hist / rows); | 920 | if (next === this.scrollPages) return false; |
| 841 | if (this.scrollPages < maxPages) this.scrollPages++; | 921 | if (next === 0) { this.exitScroll(); return true; } |
| 842 | else return; | 922 | const fromPages = this.scrollPages; |
| 843 | } else { | 923 | this.scrollPages = next; |
| 844 | if (this.scrollPages === 0) return; | ||
| 845 | // The last page down IS exitScroll — badge, full repaint, live | ||
| 846 | // paint — so it goes there rather than spelling the exit a second | ||
| 847 | // time. exitScroll refuses a tile that is already live, hence the | ||
| 848 | // test on 1 rather than a decrement to 0. | ||
| 849 | if (this.scrollPages === 1) { this.exitScroll(); return; } | ||
| 850 | this.scrollPages--; | ||
| 851 | } | ||
| 852 | this.renderBadge(); | 924 | this.renderBadge(); |
| 853 | const start = this.core.mux_scroll_start(this.scrollPages, rows); | 925 | const start = this.core.mux_scroll_start(this.scrollPages, rows); |
| 854 | this.requestedViewStartRow = start; | 926 | this.requestedViewStartRow = start; |
| 927 | this.scrollRequest = { fromPages, start }; | ||
| 855 | const p = new Uint8Array(6); | 928 | const p = new Uint8Array(6); |
| 856 | const dv = new DataView(p.buffer); | 929 | const dv = new DataView(p.buffer); |
| 857 | dv.setUint32(0, start, true); | 930 | dv.setUint32(0, start, true); |
| 858 | dv.setUint16(4, rows, true); | 931 | dv.setUint16(4, rows, true); |
| 859 | this.sendFrame(MSG.fetch_scrollback, p); | 932 | this.sendFrame(MSG.fetch_scrollback, p); |
| 933 | return true; | ||
| 934 | } | ||
| 935 | |||
| 936 | scrollRequestFailed(request) { | ||
| 937 | if (this.scrollRequest !== request) return; | ||
| 938 | this.scrollRequest = null; | ||
| 939 | this.scrollPages = request.fromPages; | ||
| 940 | this.requestedViewStartRow = this.viewStartRow; | ||
| 941 | this.renderBadge(); | ||
| 942 | } | ||
| 943 | |||
| 944 | onWheel(ev) { | ||
| 945 | this.changeScrollPages(ev.deltaY < 0 ? 1 : -1); | ||
| 860 | } | 946 | } |
| 861 | 947 | ||
| 862 | exitScroll() { | 948 | exitScroll() { |
| 863 | if (this.scrollPages === 0) return; | 949 | if (this.scrollPages === 0) return; |
| 950 | this.scrollRequest = null; | ||
| 864 | this.scrollPages = 0; | 951 | this.scrollPages = 0; |
| 865 | this.renderBadge(); | 952 | this.renderBadge(); |
| 866 | this.reflow(); // mark-all + paint live: the same repaint, one owner | 953 | this.reflow(); // mark-all + paint live: the same repaint, one owner |
| @@ -947,6 +1034,7 @@ function unzoom() { | |||
| 947 | const was = zoomedTile; | 1034 | const was = zoomedTile; |
| 948 | // Invalidate every outstanding write and feedback timer before any | 1035 | // Invalidate every outstanding write and feedback timer before any |
| 949 | // exit-scroll or wall reflow work can run. | 1036 | // exit-scroll or wall reflow work can run. |
| 1037 | was.clearSelection(false); | ||
| 950 | was.clipboardVersion++; | 1038 | was.clipboardVersion++; |
| 951 | was.pendingClipboard = null; | 1039 | was.pendingClipboard = null; |
| 952 | was.renderCopyUi(null, 'copy-request', 'Copy'); | 1040 | was.renderCopyUi(null, 'copy-request', 'Copy'); |
| @@ -985,6 +1073,19 @@ document.addEventListener('keydown', (ev) => { | |||
| 985 | t.copyButton.focus(); | 1073 | t.copyButton.focus(); |
| 986 | return; | 1074 | return; |
| 987 | } | 1075 | } |
| 1076 | const lower = ev.key.toLowerCase(); | ||
| 1077 | const hasSelection = t.selection?.text !== null && t.selection?.text !== undefined; | ||
| 1078 | // Copy is terminal-owned only when daemon-authoritative text exists. | ||
| 1079 | // With no retained result, Firefox keeps Ctrl+Shift+C for Inspector and | ||
| 1080 | // macOS keeps its Cmd family; plain Ctrl+C continues to the keymap. | ||
| 1081 | const selectionCopy = lower === 'c' && ( | ||
| 1082 | ev.metaKey || (ev.ctrlKey && !ev.altKey) | ||
| 1083 | ); | ||
| 1084 | if (selectionCopy && hasSelection) { | ||
| 1085 | ev.preventDefault(); | ||
| 1086 | t.copySelection(); | ||
| 1087 | return; | ||
| 1088 | } | ||
| 988 | const mods = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0); | 1089 | const mods = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0); |
| 989 | // Leave genuine browser chords alone (copy/paste arrive as events): | 1090 | // Leave genuine browser chords alone (copy/paste arrive as events): |
| 990 | // Ctrl+Shift+C/V everywhere, and on macOS the WHOLE Cmd family, which | 1091 | // Ctrl+Shift+C/V everywhere, and on macOS the WHOLE Cmd family, which |
| @@ -993,8 +1094,9 @@ document.addEventListener('keydown', (ev) => { | |||
| 993 | // No preventDefault and no bytes — the keymap has no meta concept by | 1094 | // No preventDefault and no bytes — the keymap has no meta concept by |
| 994 | // design, so a Cmd chord the browser does not want is simply dropped. | 1095 | // design, so a Cmd chord the browser does not want is simply dropped. |
| 995 | if (ev.metaKey) return; | 1096 | if (ev.metaKey) return; |
| 996 | if (ev.ctrlKey && ev.shiftKey && (ev.key.toLowerCase() === 'c' || ev.key.toLowerCase() === 'v')) return; | 1097 | if (ev.ctrlKey && ev.shiftKey && (lower === 'c' || lower === 'v')) return; |
| 997 | if (t.scrollPages > 0 && ev.key !== 'PageUp' && ev.key !== 'PageDown') { | 1098 | if (t.scrollPages > 0 && ev.key !== 'PageUp' && ev.key !== 'PageDown') { |
| 1099 | t.clearSelection(false); | ||
| 998 | t.exitScroll(); // any other key leaves scroll mode, swallowed (CLI rule) | 1100 | t.exitScroll(); // any other key leaves scroll mode, swallowed (CLI rule) |
| 999 | ev.preventDefault(); | 1101 | ev.preventDefault(); |
| 1000 | return; | 1102 | return; |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -723,7 +723,9 @@ async function verifySelectionShell(shell, html) { | |||
| 723 | && tile.nextSelectionId === 0 | 723 | && tile.nextSelectionId === 0 |
| 724 | && tile.drag === null | 724 | && tile.drag === null |
| 725 | && tile.lastPointerY === null | 725 | && tile.lastPointerY === null |
| 726 | && tile.selectionScrollTimer === null; | 726 | && tile.selectionScrollTimer === null |
| 727 | && tile.scrollRequest === null | ||
| 728 | && tile.selectionCopyVersion === 0; | ||
| 727 | check('tile initializes retained selection state', initialState, true); | 729 | check('tile initializes retained selection state', initialState, true); |
| 728 | check('tile initializes requested viewport state separately', tile.requestedViewStartRow, 0); | 730 | check('tile initializes requested viewport state separately', tile.requestedViewStartRow, 0); |
| 729 | check('tile initializes shared copy-control ownership', tile.copyUiOwner, null); | 731 | check('tile initializes shared copy-control ownership', tile.copyUiOwner, null); |
| @@ -745,6 +747,7 @@ async function verifySelectionShell(shell, html) { | |||
| 745 | const selectionMethods = [ | 747 | const selectionMethods = [ |
| 746 | 'cellAtPointer', 'orderedSelection', 'clearSelection', 'paintSelection', | 748 | 'cellAtPointer', 'orderedSelection', 'clearSelection', 'paintSelection', |
| 747 | 'beginSelection', 'moveSelection', 'endSelection', 'onSelectionReply', | 749 | 'beginSelection', 'moveSelection', 'endSelection', 'onSelectionReply', |
| 750 | 'changeScrollPages', 'autoScrollSelection', 'writeClipboardText', 'copySelection', | ||
| 748 | ]; | 751 | ]; |
| 749 | check( | 752 | check( |
| 750 | 'tile exposes retained-selection behavior', | 753 | 'tile exposes retained-selection behavior', |
| @@ -826,6 +829,18 @@ async function verifySelectionShell(shell, html) { | |||
| 826 | wasPrevented: () => prevented, | 829 | wasPrevented: () => prevented, |
| 827 | }; | 830 | }; |
| 828 | }; | 831 | }; |
| 832 | const scrollEnvelope = (start, count = 5, body = [1, 2, 3]) => { | ||
| 833 | const chunk = new Uint8Array(6 + body.length); | ||
| 834 | new DataView(chunk.buffer).setUint32(0, start, true); | ||
| 835 | new DataView(chunk.buffer).setUint16(4, count, true); | ||
| 836 | chunk.set(body, 6); | ||
| 837 | const envelope = new Uint8Array(6 + chunk.length); | ||
| 838 | envelope[0] = 0; | ||
| 839 | envelope[1] = 0x85; | ||
| 840 | new DataView(envelope.buffer).setUint32(2, chunk.length, true); | ||
| 841 | envelope.set(chunk, 6); | ||
| 842 | return envelope; | ||
| 843 | }; | ||
| 829 | 844 | ||
| 830 | const coordinate = makeTile(); | 845 | const coordinate = makeTile(); |
| 831 | coordinate.tile.viewStartRow = 40; | 846 | coordinate.tile.viewStartRow = 40; |
| @@ -980,7 +995,7 @@ async function verifySelectionShell(shell, html) { | |||
| 980 | let paintedScroll = 0; | 995 | let paintedScroll = 0; |
| 981 | scrolling.tile.paintScroll = () => { paintedScroll++; }; | 996 | scrolling.tile.paintScroll = () => { paintedScroll++; }; |
| 982 | const chunk = new Uint8Array(6 + 3); | 997 | const chunk = new Uint8Array(6 + 3); |
| 983 | new DataView(chunk.buffer).setUint32(0, 17, true); | 998 | new DataView(chunk.buffer).setUint32(0, 25, true); |
| 984 | new DataView(chunk.buffer).setUint16(4, 5, true); | 999 | new DataView(chunk.buffer).setUint16(4, 5, true); |
| 985 | chunk.set([1, 2, 3], 6); | 1000 | chunk.set([1, 2, 3], 6); |
| 986 | const envelope = new Uint8Array(6 + chunk.length); | 1001 | const envelope = new Uint8Array(6 + chunk.length); |
| @@ -989,7 +1004,7 @@ async function verifySelectionShell(shell, html) { | |||
| 989 | new DataView(envelope.buffer).setUint32(2, chunk.length, true); | 1004 | new DataView(envelope.buffer).setUint32(2, chunk.length, true); |
| 990 | envelope.set(chunk, 6); | 1005 | envelope.set(chunk, 6); |
| 991 | scrolling.tile.onMessage(envelope); | 1006 | scrolling.tile.onMessage(envelope); |
| 992 | check('scrollback echo is authoritative for displayed start row', scrolling.tile.viewStartRow, 17); | 1007 | check('matching scrollback echo becomes the displayed start row', scrolling.tile.viewStartRow, 25); |
| 993 | check('scrollback echo paints the history viewport', paintedScroll, 1); | 1008 | check('scrollback echo paints the history viewport', paintedScroll, 1); |
| 994 | 1009 | ||
| 995 | const failedStage = makeTile(); | 1010 | const failedStage = makeTile(); |
| @@ -1006,6 +1021,132 @@ async function verifySelectionShell(shell, html) { | |||
| 1006 | failedFeed.tile.onMessage(envelope); | 1021 | failedFeed.tile.onMessage(envelope); |
| 1007 | check('failed scrollback decode preserves painted start row', failedFeed.tile.viewStartRow, 30); | 1022 | check('failed scrollback decode preserves painted start row', failedFeed.tile.viewStartRow, 30); |
| 1008 | 1023 | ||
| 1024 | const auto = makeTile(); | ||
| 1025 | auto.tile.viewStartRow = 30; | ||
| 1026 | auto.tile.requestedViewStartRow = 30; | ||
| 1027 | let autoPaints = 0; | ||
| 1028 | auto.tile.paintScroll = () => { autoPaints++; }; | ||
| 1029 | auto.tile.canvas.dispatchEvent('pointerdown', pointer(18, 2, 2)); | ||
| 1030 | auto.tile.canvas.dispatchEvent('pointermove', { | ||
| 1031 | ...pointer(18, 4, 0), clientY: 10, | ||
| 1032 | }); | ||
| 1033 | const autoTimer = h.intervals[auto.tile.selectionScrollTimer - 1]; | ||
| 1034 | autoTimer.fn(); | ||
| 1035 | check('outside drag timer requests one older page without pointermove', auto.sent.length, 1); | ||
| 1036 | check('auto-scroll uses fetch-scrollback wire type', auto.sent[0]?.type, 0x05); | ||
| 1037 | check('auto-scroll keeps coordinates tied to painted page while pending', auto.tile.selection.active.row, 30); | ||
| 1038 | check('auto-scroll keeps painted start unchanged while pending', auto.tile.viewStartRow, 30); | ||
| 1039 | check('auto-scroll records requested page separately', auto.tile.requestedViewStartRow, 25); | ||
| 1040 | autoTimer.fn(); | ||
| 1041 | check('auto-scroll does not stack requests while a paint is pending', auto.sent.length, 1); | ||
| 1042 | auto.tile.onMessage(scrollEnvelope(20)); | ||
| 1043 | check('out-of-order history reply cannot move the painted viewport', auto.tile.viewStartRow, 30); | ||
| 1044 | check('out-of-order history reply cannot move the drag endpoint', auto.tile.selection.active.row, 30); | ||
| 1045 | check('out-of-order history reply does not paint', autoPaints, 0); | ||
| 1046 | auto.tile.onMessage(scrollEnvelope(25)); | ||
| 1047 | check('matching successful history reply advances painted viewport', auto.tile.viewStartRow, 25); | ||
| 1048 | check('matching successful history reply moves drag to painted boundary', auto.tile.selection.active.row, 25); | ||
| 1049 | check('matching successful history reply retains boundary view row', auto.tile.selection.active.viewRow, 0); | ||
| 1050 | check('matching successful history reply paints once', autoPaints, 1); | ||
| 1051 | autoTimer.fn(); | ||
| 1052 | check('timer continues to the next page while pointer remains outside', auto.sent.length, 2); | ||
| 1053 | check( | ||
| 1054 | 'continued auto-scroll requests exact next viewport', | ||
| 1055 | Buffer.from(auto.sent[1]?.payload ?? []).toString('hex'), | ||
| 1056 | '140000000500', | ||
| 1057 | ); | ||
| 1058 | auto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 18 }); | ||
| 1059 | |||
| 1060 | const failedAuto = makeTile(); | ||
| 1061 | failedAuto.tile.viewStartRow = 30; | ||
| 1062 | failedAuto.tile.requestedViewStartRow = 30; | ||
| 1063 | failedAuto.tile.canvas.dispatchEvent('pointerdown', pointer(19, 2, 2)); | ||
| 1064 | failedAuto.tile.canvas.dispatchEvent('pointermove', { | ||
| 1065 | ...pointer(19, 4, 0), clientY: 10, | ||
| 1066 | }); | ||
| 1067 | const failedAutoTimer = h.intervals[failedAuto.tile.selectionScrollTimer - 1]; | ||
| 1068 | failedAutoTimer.fn(); | ||
| 1069 | failedAuto.setScrollFeedResult(-3); | ||
| 1070 | failedAuto.tile.onMessage(scrollEnvelope(25)); | ||
| 1071 | check('failed auto-scroll reply preserves painted viewport', failedAuto.tile.viewStartRow, 30); | ||
| 1072 | check('failed auto-scroll reply preserves displayed endpoint', failedAuto.tile.selection.active.row, 30); | ||
| 1073 | check('failed auto-scroll reply rolls requested page back', failedAuto.tile.scrollPages, 0); | ||
| 1074 | failedAuto.setScrollFeedResult(0); | ||
| 1075 | failedAutoTimer.fn(); | ||
| 1076 | check('auto-scroll retries the same page after a failed decode', failedAuto.sent.length, 2); | ||
| 1077 | check( | ||
| 1078 | 'auto-scroll retry keeps exact failed viewport coordinates', | ||
| 1079 | Buffer.from(failedAuto.sent[1]?.payload ?? []).toString('hex'), | ||
| 1080 | '190000000500', | ||
| 1081 | ); | ||
| 1082 | failedAuto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 19 }); | ||
| 1083 | |||
| 1084 | const failedAutoStage = makeTile(); | ||
| 1085 | failedAutoStage.tile.viewStartRow = 30; | ||
| 1086 | failedAutoStage.tile.requestedViewStartRow = 30; | ||
| 1087 | failedAutoStage.tile.canvas.dispatchEvent('pointerdown', pointer(22, 2, 2)); | ||
| 1088 | failedAutoStage.tile.canvas.dispatchEvent('pointermove', { | ||
| 1089 | ...pointer(22, 4, 0), clientY: 10, | ||
| 1090 | }); | ||
| 1091 | const failedStageTimer = h.intervals[failedAutoStage.tile.selectionScrollTimer - 1]; | ||
| 1092 | failedStageTimer.fn(); | ||
| 1093 | failedAutoStage.tile.stage = () => false; | ||
| 1094 | failedAutoStage.tile.onMessage(scrollEnvelope(25)); | ||
| 1095 | check('failed auto-scroll staging rolls requested page back', failedAutoStage.tile.scrollPages, 0); | ||
| 1096 | check('failed auto-scroll staging preserves painted endpoint', failedAutoStage.tile.selection.active.row, 30); | ||
| 1097 | failedAutoStage.tile.stage = h.Tile.prototype.stage.bind(failedAutoStage.tile); | ||
| 1098 | failedStageTimer.fn(); | ||
| 1099 | check('auto-scroll retries after failed staging', failedAutoStage.sent.length, 2); | ||
| 1100 | failedAutoStage.tile.canvas.dispatchEvent('pointercancel', { pointerId: 22 }); | ||
| 1101 | |||
| 1102 | const malformedAuto = makeTile(); | ||
| 1103 | malformedAuto.tile.viewStartRow = 30; | ||
| 1104 | malformedAuto.tile.requestedViewStartRow = 30; | ||
| 1105 | malformedAuto.tile.canvas.dispatchEvent('pointerdown', pointer(24, 2, 2)); | ||
| 1106 | malformedAuto.tile.canvas.dispatchEvent('pointermove', { | ||
| 1107 | ...pointer(24, 4, 0), clientY: 10, | ||
| 1108 | }); | ||
| 1109 | h.intervals[malformedAuto.tile.selectionScrollTimer - 1].fn(); | ||
| 1110 | const malformedScroll = new Uint8Array([0, 0x85, 1, 0, 0, 0, 0]); | ||
| 1111 | malformedAuto.tile.onMessage(malformedScroll); | ||
| 1112 | check('malformed matching scroll lane releases pending page intent', malformedAuto.tile.scrollPages, 0); | ||
| 1113 | check('malformed matching scroll lane preserves painted start', malformedAuto.tile.viewStartRow, 30); | ||
| 1114 | malformedAuto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 24 }); | ||
| 1115 | |||
| 1116 | const edgeAuto = makeTile(); | ||
| 1117 | edgeAuto.tile.scrollPages = 6; | ||
| 1118 | edgeAuto.tile.viewStartRow = 0; | ||
| 1119 | edgeAuto.tile.requestedViewStartRow = 0; | ||
| 1120 | edgeAuto.tile.canvas.dispatchEvent('pointerdown', pointer(20, 3, 0)); | ||
| 1121 | edgeAuto.tile.canvas.dispatchEvent('pointermove', { | ||
| 1122 | ...pointer(20, 3, 0), clientY: 10, | ||
| 1123 | }); | ||
| 1124 | h.intervals[edgeAuto.tile.selectionScrollTimer - 1].fn(); | ||
| 1125 | check('auto-scroll is bounded at oldest history page', edgeAuto.sent.length, 0); | ||
| 1126 | check('bounded auto-scroll retains painted endpoint', edgeAuto.tile.selection.active.row, 0); | ||
| 1127 | edgeAuto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 20 }); | ||
| 1128 | |||
| 1129 | const releaseRace = makeTile(); | ||
| 1130 | releaseRace.tile.viewStartRow = 30; | ||
| 1131 | releaseRace.tile.requestedViewStartRow = 30; | ||
| 1132 | let releasePaints = 0; | ||
| 1133 | releaseRace.tile.paintScroll = () => { releasePaints++; }; | ||
| 1134 | releaseRace.tile.canvas.dispatchEvent('pointerdown', pointer(21, 2, 2)); | ||
| 1135 | const outside = { ...pointer(21, 4, 0), clientY: 10 }; | ||
| 1136 | releaseRace.tile.canvas.dispatchEvent('pointermove', outside); | ||
| 1137 | h.intervals[releaseRace.tile.selectionScrollTimer - 1].fn(); | ||
| 1138 | releaseRace.tile.canvas.dispatchEvent('pointerup', outside); | ||
| 1139 | check( | ||
| 1140 | 'release before scroll reply requests only displayed coordinates', | ||
| 1141 | JSON.stringify(releaseRace.requestCalls), | ||
| 1142 | JSON.stringify([[1, 32, 2, 30, 4]]), | ||
| 1143 | ); | ||
| 1144 | const finalizedActive = JSON.stringify(releaseRace.tile.selection.active); | ||
| 1145 | releaseRace.tile.onMessage(scrollEnvelope(25)); | ||
| 1146 | check('late scroll reply after release may paint requested viewport', releasePaints, 1); | ||
| 1147 | check('late scroll reply after release cannot change finalized selection', JSON.stringify(releaseRace.tile.selection.active), finalizedActive); | ||
| 1148 | check('late scroll reply after release sends no second selection request', releaseRace.requestCalls.length, 1); | ||
| 1149 | |||
| 1009 | const painted = makeTile(); | 1150 | const painted = makeTile(); |
| 1010 | const order = []; | 1151 | const order = []; |
| 1011 | painted.tile.sizeCanvas = () => {}; | 1152 | painted.tile.sizeCanvas = () => {}; |
| @@ -1185,6 +1326,268 @@ async function verifySelectionShell(shell, html) { | |||
| 1185 | successfulUi.tile.onSelectionReply(); | 1326 | successfulUi.tile.onSelectionReply(); |
| 1186 | check('successful selection removes stale unavailable UI', `${successfulUi.tile.copyButton.className}|${successfulUi.tile.copyButton.textContent}`, 'copy-request|Copy'); | 1327 | check('successful selection removes stale unavailable UI', `${successfulUi.tile.copyButton.className}|${successfulUi.tile.copyButton.textContent}`, 'copy-request|Copy'); |
| 1187 | 1328 | ||
| 1329 | const keyEvent = (overrides = {}) => { | ||
| 1330 | let prevented = false; | ||
| 1331 | return { | ||
| 1332 | key: '', target: h.elements.ime, isComposing: false, | ||
| 1333 | shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, | ||
| 1334 | preventDefault() { prevented = true; }, | ||
| 1335 | wasPrevented: () => prevented, | ||
| 1336 | ...overrides, | ||
| 1337 | }; | ||
| 1338 | }; | ||
| 1339 | const authoritativeSelection = (text) => ({ | ||
| 1340 | anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 80, text, | ||
| 1341 | }); | ||
| 1342 | |||
| 1343 | for (const [name, modifiers] of [ | ||
| 1344 | ['Ctrl+Shift+C', { ctrlKey: true, shiftKey: true }], | ||
| 1345 | ['Ctrl+C', { ctrlKey: true }], | ||
| 1346 | ['Meta+C', { metaKey: true }], | ||
| 1347 | ]) { | ||
| 1348 | const chord = makeTile(); | ||
| 1349 | chord.tile.selection = authoritativeSelection(`exact ${name}`); | ||
| 1350 | h.setZoomedTile(chord.tile); | ||
| 1351 | const writes = []; | ||
| 1352 | h.navigator.clipboard = { writeText(text) { writes.push(text); return Promise.resolve(); } }; | ||
| 1353 | const ev = keyEvent({ key: 'c', ...modifiers }); | ||
| 1354 | h.document.dispatchEvent('keydown', ev); | ||
| 1355 | check(`${name} with authoritative selection prevents browser default`, ev.wasPrevented(), true); | ||
| 1356 | check(`${name} invokes clipboard in the keydown user gesture`, writes.join('|'), `exact ${name}`); | ||
| 1357 | check(`${name} with authoritative selection sends no PTY frame`, chord.sent.length, 0); | ||
| 1358 | await flushPromises(); | ||
| 1359 | check(`${name} retains the selection after copy`, chord.tile.selection?.text, `exact ${name}`); | ||
| 1360 | } | ||
| 1361 | |||
| 1362 | const emptyCopy = makeTile(); | ||
| 1363 | emptyCopy.tile.selection = authoritativeSelection(''); | ||
| 1364 | h.setZoomedTile(emptyCopy.tile); | ||
| 1365 | const emptyWrites = []; | ||
| 1366 | h.navigator.clipboard = { writeText(text) { emptyWrites.push(text); return Promise.resolve(); } }; | ||
| 1367 | const emptyEvent = keyEvent({ key: 'c', ctrlKey: true }); | ||
| 1368 | h.document.dispatchEvent('keydown', emptyEvent); | ||
| 1369 | check('empty authoritative selection is still copyable', emptyEvent.wasPrevented(), true); | ||
| 1370 | check('empty authoritative selection writes exact empty text', JSON.stringify(emptyWrites), '[""]'); | ||
| 1371 | await flushPromises(); | ||
| 1372 | check('empty selection copy retains authoritative empty text', emptyCopy.tile.selection?.text, ''); | ||
| 1373 | |||
| 1374 | const nativeChords = makeTile(); | ||
| 1375 | h.setZoomedTile(nativeChords.tile); | ||
| 1376 | for (const [name, modifiers] of [ | ||
| 1377 | ['Ctrl+Shift+C', { ctrlKey: true, shiftKey: true }], | ||
| 1378 | ['Meta+C', { metaKey: true }], | ||
| 1379 | ]) { | ||
| 1380 | const ev = keyEvent({ key: 'c', ...modifiers }); | ||
| 1381 | h.document.dispatchEvent('keydown', ev); | ||
| 1382 | check(`${name} without selection remains browser-native`, ev.wasPrevented(), false); | ||
| 1383 | } | ||
| 1384 | check('browser-native copy chords without selection send no PTY frame', nativeChords.sent.length, 0); | ||
| 1385 | |||
| 1386 | const terminalCopy = makeTile(); | ||
| 1387 | const encodedKeys = []; | ||
| 1388 | terminalCopy.tile.core.mux_key_encode = (...args) => { | ||
| 1389 | encodedKeys.push(args); | ||
| 1390 | new Uint8Array(terminalCopy.memory.buffer)[256] = 3; | ||
| 1391 | return 1; | ||
| 1392 | }; | ||
| 1393 | terminalCopy.tile.core.mux_output_len = () => 1; | ||
| 1394 | h.setZoomedTile(terminalCopy.tile); | ||
| 1395 | const ctrlC = keyEvent({ key: 'c', ctrlKey: true }); | ||
| 1396 | h.document.dispatchEvent('keydown', ctrlC); | ||
| 1397 | check('Ctrl+C without selection prevents browser default for terminal input', ctrlC.wasPrevented(), true); | ||
| 1398 | check('Ctrl+C without selection reaches terminal key encoding', JSON.stringify(encodedKeys), '[[0,99,4]]'); | ||
| 1399 | check('Ctrl+C without selection sends one PTY frame', terminalCopy.sent[0]?.type, 0x02); | ||
| 1400 | check('Ctrl+C fake encoder emits ETX byte', terminalCopy.sent[0]?.payload[0], 3); | ||
| 1401 | |||
| 1402 | const altCopy = makeTile(); | ||
| 1403 | const altKeys = []; | ||
| 1404 | altCopy.tile.selection = authoritativeSelection('must not copy'); | ||
| 1405 | altCopy.tile.core.mux_key_encode = (...args) => { altKeys.push(args); return 1; }; | ||
| 1406 | altCopy.tile.core.mux_output_len = () => 1; | ||
| 1407 | h.setZoomedTile(altCopy.tile); | ||
| 1408 | let altWrites = 0; | ||
| 1409 | h.navigator.clipboard = { writeText() { altWrites++; return Promise.resolve(); } }; | ||
| 1410 | const ctrlAltC = keyEvent({ key: 'c', ctrlKey: true, altKey: true }); | ||
| 1411 | h.document.dispatchEvent('keydown', ctrlAltC); | ||
| 1412 | check('Ctrl+Alt+C is terminal input, not selection copy', altWrites, 0); | ||
| 1413 | check('Ctrl+Alt+C includes Alt and Ctrl in terminal modifiers', JSON.stringify(altKeys), '[[0,99,6]]'); | ||
| 1414 | check('Ctrl+Alt+C clears retained selection through sendKey', altCopy.tile.selection, null); | ||
| 1415 | |||
| 1416 | const nativePaste = makeTile(); | ||
| 1417 | nativePaste.tile.selection = authoritativeSelection('keep during browser paste chord'); | ||
| 1418 | h.setZoomedTile(nativePaste.tile); | ||
| 1419 | const ctrlShiftV = keyEvent({ key: 'v', ctrlKey: true, shiftKey: true }); | ||
| 1420 | h.document.dispatchEvent('keydown', ctrlShiftV); | ||
| 1421 | check('Ctrl+Shift+V remains browser-native', ctrlShiftV.wasPrevented(), false); | ||
| 1422 | check('Ctrl+Shift+V sends no PTY frame', nativePaste.sent.length, 0); | ||
| 1423 | check('Ctrl+Shift+V keydown alone does not clear selection', nativePaste.tile.selection?.text, 'keep during browser paste chord'); | ||
| 1424 | |||
| 1425 | const composingCopy = makeTile(); | ||
| 1426 | composingCopy.tile.selection = authoritativeSelection('IME owns this key'); | ||
| 1427 | h.setZoomedTile(composingCopy.tile); | ||
| 1428 | let composingWrites = 0; | ||
| 1429 | h.navigator.clipboard = { writeText() { composingWrites++; return Promise.resolve(); } }; | ||
| 1430 | const composingEvent = keyEvent({ key: 'c', ctrlKey: true, isComposing: true }); | ||
| 1431 | h.document.dispatchEvent('keydown', composingEvent); | ||
| 1432 | check('composition handling precedes selection copy chords', composingWrites, 0); | ||
| 1433 | check('composing copy chord remains unprevented', composingEvent.wasPrevented(), false); | ||
| 1434 | |||
| 1435 | const scrollExitKey = makeTile(); | ||
| 1436 | scrollExitKey.tile.scrollPages = 1; | ||
| 1437 | scrollExitKey.tile.viewStartRow = 25; | ||
| 1438 | scrollExitKey.tile.requestedViewStartRow = 25; | ||
| 1439 | scrollExitKey.tile.selection = authoritativeSelection('history selection'); | ||
| 1440 | h.setZoomedTile(scrollExitKey.tile); | ||
| 1441 | const swallowedHistoryKey = keyEvent({ key: 'x' }); | ||
| 1442 | h.document.dispatchEvent('keydown', swallowedHistoryKey); | ||
| 1443 | check('key swallowed to leave history still clears retained selection', scrollExitKey.tile.selection, null); | ||
| 1444 | check('history-exit key remains swallowed without PTY bytes', scrollExitKey.sent.length, 0); | ||
| 1445 | |||
| 1446 | const inputClears = makeTile(); | ||
| 1447 | inputClears.tile.core.mux_key_encode = () => 1; | ||
| 1448 | inputClears.tile.core.mux_text_encode = (len) => len; | ||
| 1449 | inputClears.tile.core.mux_output_len = () => 1; | ||
| 1450 | inputClears.tile.selection = authoritativeSelection('typed over'); | ||
| 1451 | inputClears.tile.sendKey(0, 120, 0); | ||
| 1452 | check('sendKey clears retained selection before terminal output', inputClears.tile.selection, null); | ||
| 1453 | check('sendKey clear does not duplicate terminal output', inputClears.sent.length, 1); | ||
| 1454 | inputClears.tile.selection = authoritativeSelection('IME over'); | ||
| 1455 | inputClears.tile.sendText('x'); | ||
| 1456 | check('sendText clears retained selection before terminal output', inputClears.tile.selection, null); | ||
| 1457 | check('sendText clear does not duplicate terminal output', inputClears.sent.length, 2); | ||
| 1458 | |||
| 1459 | const pasteClears = makeTile(); | ||
| 1460 | pasteClears.tile.core.mux_paste_begin = () => 0; | ||
| 1461 | pasteClears.tile.core.mux_paste_end = () => 0; | ||
| 1462 | pasteClears.tile.core.mux_text_encode = (len) => len; | ||
| 1463 | pasteClears.tile.core.mux_output_len = () => 1; | ||
| 1464 | pasteClears.tile.selection = authoritativeSelection('paste over'); | ||
| 1465 | h.setZoomedTile(pasteClears.tile); | ||
| 1466 | let pastePrevented = false; | ||
| 1467 | h.document.dispatchEvent('paste', { | ||
| 1468 | preventDefault() { pastePrevented = true; }, | ||
| 1469 | clipboardData: { getData: () => 'p' }, | ||
| 1470 | }); | ||
| 1471 | check('real paste handler prevents browser insertion', pastePrevented, true); | ||
| 1472 | check('real paste handler clears retained selection through sendText', pasteClears.tile.selection, null); | ||
| 1473 | check('real paste handler emits one unwrapped text frame', pasteClears.sent.length, 1); | ||
| 1474 | |||
| 1475 | const compositionClears = makeTile(); | ||
| 1476 | compositionClears.tile.core.mux_text_encode = (len) => len; | ||
| 1477 | compositionClears.tile.core.mux_output_len = () => 1; | ||
| 1478 | compositionClears.tile.selection = authoritativeSelection('composition over'); | ||
| 1479 | h.setZoomedTile(compositionClears.tile); | ||
| 1480 | h.elements.ime.value = 'stale'; | ||
| 1481 | h.elements.ime.dispatchEvent('compositionend', { data: '漢' }); | ||
| 1482 | check('real composition handler clears retained selection through sendText', compositionClears.tile.selection, null); | ||
| 1483 | check('real composition handler emits one terminal text frame', compositionClears.sent.length, 1); | ||
| 1484 | check('composition handler still clears hidden IME value', h.elements.ime.value, ''); | ||
| 1485 | |||
| 1486 | const sharedWriter = makeTile(); | ||
| 1487 | const sharedCalls = []; | ||
| 1488 | sharedWriter.tile.writeClipboardText = (text) => { sharedCalls.push(text); return Promise.resolve(); }; | ||
| 1489 | const encodedOsc = Buffer.from('shared OSC writer', 'utf8').toString('base64'); | ||
| 1490 | new Uint8Array(sharedWriter.memory.buffer).set(Buffer.from(encodedOsc, 'ascii'), 96); | ||
| 1491 | sharedWriter.tile.core.mux_clipboard_ptr = () => 96; | ||
| 1492 | sharedWriter.tile.core.mux_clipboard_len = () => encodedOsc.length; | ||
| 1493 | await sharedWriter.tile.onClipboardEffect(); | ||
| 1494 | sharedWriter.tile.selection = authoritativeSelection('shared selection writer'); | ||
| 1495 | await sharedWriter.tile.copySelection(); | ||
| 1496 | check('OSC52 and explicit selection share the low-level writer', sharedCalls.join('|'), 'shared OSC writer|shared selection writer'); | ||
| 1497 | |||
| 1498 | const clipboardRace = makeTile(); | ||
| 1499 | clipboardRace.tile.pendingClipboard = 'older OSC52'; | ||
| 1500 | clipboardRace.tile.clipboardVersion = 1; | ||
| 1501 | clipboardRace.tile.copyUiOwner = 'clipboard'; | ||
| 1502 | const oldOsc = deferred(), explicitCopy = deferred(); | ||
| 1503 | const raceCalls = []; | ||
| 1504 | h.navigator.clipboard = { writeText(text) { | ||
| 1505 | raceCalls.push(text); | ||
| 1506 | return text === 'older OSC52' ? oldOsc.promise : explicitCopy.promise; | ||
| 1507 | } }; | ||
| 1508 | const oldOscRun = clipboardRace.tile.tryClipboardWrite(1, true); | ||
| 1509 | clipboardRace.tile.selection = authoritativeSelection('new explicit selection'); | ||
| 1510 | h.setZoomedTile(clipboardRace.tile); | ||
| 1511 | const raceEvent = keyEvent({ key: 'c', ctrlKey: true }); | ||
| 1512 | h.document.dispatchEvent('keydown', raceEvent); | ||
| 1513 | check('explicit selection copy starts immediately while OSC52 is active', raceCalls.join('|'), 'older OSC52|new explicit selection'); | ||
| 1514 | check('selection copy owns shared UI while writes overlap', clipboardRace.tile.copyUiOwner, 'selection'); | ||
| 1515 | explicitCopy.resolve(); | ||
| 1516 | await flushPromises(); | ||
| 1517 | check('explicit selection success reports Copied', `${clipboardRace.tile.copyButton.className}|${clipboardRace.tile.copyButton.textContent}`, 'copy-request on|Copied'); | ||
| 1518 | oldOsc.resolve(); | ||
| 1519 | await oldOscRun; | ||
| 1520 | check('older OSC52 settlement cannot overwrite selection-copy success', `${clipboardRace.tile.copyButton.className}|${clipboardRace.tile.copyButton.textContent}`, 'copy-request on|Copied'); | ||
| 1521 | check('older OSC52 still completes its serialized pending state', clipboardRace.tile.pendingClipboard, null); | ||
| 1522 | |||
| 1523 | const oldTimerRace = makeTile(); | ||
| 1524 | oldTimerRace.tile.pendingClipboard = 'prior copied OSC52'; | ||
| 1525 | oldTimerRace.tile.clipboardVersion = 1; | ||
| 1526 | oldTimerRace.tile.copyUiOwner = 'clipboard'; | ||
| 1527 | h.navigator.clipboard = { writeText: () => Promise.resolve() }; | ||
| 1528 | await oldTimerRace.tile.tryClipboardWrite(1, true); | ||
| 1529 | const priorOscTimer = h.timers.at(-1); | ||
| 1530 | oldTimerRace.tile.selection = authoritativeSelection('selection after OSC timer'); | ||
| 1531 | const selectionTimerWrite = deferred(); | ||
| 1532 | h.navigator.clipboard = { writeText: () => selectionTimerWrite.promise }; | ||
| 1533 | const selectionTimerRun = oldTimerRace.tile.copySelection(); | ||
| 1534 | selectionTimerWrite.reject(new Error('selection denied')); | ||
| 1535 | await selectionTimerRun; | ||
| 1536 | priorOscTimer.fn(); | ||
| 1537 | check('old OSC52 timer cannot overwrite selection-copy failure', `${oldTimerRace.tile.copyButton.className}|${oldTimerRace.tile.copyButton.textContent}`, 'copy-request on error|Copy failed'); | ||
| 1538 | |||
| 1539 | const copyFailure = makeTile(); | ||
| 1540 | copyFailure.tile.selection = authoritativeSelection('retry exact selection'); | ||
| 1541 | h.setZoomedTile(copyFailure.tile); | ||
| 1542 | h.navigator.clipboard = { writeText: () => Promise.reject(new Error('denied')) }; | ||
| 1543 | const failedCopyEvent = keyEvent({ key: 'c', ctrlKey: true }); | ||
| 1544 | h.document.dispatchEvent('keydown', failedCopyEvent); | ||
| 1545 | await flushPromises(); | ||
| 1546 | check('selection clipboard rejection is handled as visible failure', `${copyFailure.tile.copyButton.className}|${copyFailure.tile.copyButton.textContent}`, 'copy-request on error|Copy failed'); | ||
| 1547 | check('selection clipboard rejection retains exact text for retry', copyFailure.tile.selection?.text, 'retry exact selection'); | ||
| 1548 | const retryWrites = []; | ||
| 1549 | h.navigator.clipboard = { writeText: (text) => { retryWrites.push(text); return Promise.resolve(); } }; | ||
| 1550 | copyFailure.tile.copyButton.dispatchEvent('click', { stopPropagation() {} }); | ||
| 1551 | await flushPromises(); | ||
| 1552 | check('selection failure button retries the retained selection only', retryWrites.join('|'), 'retry exact selection'); | ||
| 1553 | |||
| 1554 | const invalidatedCopy = makeTile(); | ||
| 1555 | invalidatedCopy.tile.selection = authoritativeSelection('old selection'); | ||
| 1556 | h.setZoomedTile(invalidatedCopy.tile); | ||
| 1557 | const pendingSelectionCopy = deferred(); | ||
| 1558 | h.navigator.clipboard = { writeText: () => pendingSelectionCopy.promise }; | ||
| 1559 | const invalidatedRun = invalidatedCopy.tile.copySelection(); | ||
| 1560 | invalidatedCopy.tile.clearSelection(false); | ||
| 1561 | invalidatedCopy.tile.selection = authoritativeSelection('new selection'); | ||
| 1562 | pendingSelectionCopy.resolve(); | ||
| 1563 | await invalidatedRun; | ||
| 1564 | check('old copy promise cannot overwrite a new selection UI', `${invalidatedCopy.tile.copyButton.className}|${invalidatedCopy.tile.copyButton.textContent}`, 'copy-request|Copy'); | ||
| 1565 | |||
| 1566 | const oldSelectionTimer = makeTile(); | ||
| 1567 | oldSelectionTimer.tile.selection = authoritativeSelection('copied old selection'); | ||
| 1568 | h.navigator.clipboard = { writeText: () => Promise.resolve() }; | ||
| 1569 | await oldSelectionTimer.tile.copySelection(); | ||
| 1570 | const oldSelectionHide = h.timers.at(-1); | ||
| 1571 | oldSelectionTimer.tile.canvas.dispatchEvent('pointerdown', pointer(23, 3, 2)); | ||
| 1572 | oldSelectionTimer.tile.renderCopyUi('selection', 'copy-request on error', 'New selection pending'); | ||
| 1573 | oldSelectionHide.fn(); | ||
| 1574 | check('old selection success timer cannot overwrite new selection UI', `${oldSelectionTimer.tile.copyButton.className}|${oldSelectionTimer.tile.copyButton.textContent}`, 'copy-request on error|New selection pending'); | ||
| 1575 | oldSelectionTimer.tile.canvas.dispatchEvent('pointercancel', { pointerId: 23 }); | ||
| 1576 | |||
| 1577 | const unzoomCopy = makeTile(); | ||
| 1578 | unzoomCopy.tile.selection = authoritativeSelection('leave during copy'); | ||
| 1579 | unzoomCopy.tile.exitScroll = () => {}; | ||
| 1580 | unzoomCopy.tile.reflow = () => {}; | ||
| 1581 | h.setZoomedTile(unzoomCopy.tile); | ||
| 1582 | const afterUnzoom = deferred(); | ||
| 1583 | h.navigator.clipboard = { writeText: () => afterUnzoom.promise }; | ||
| 1584 | const unzoomCopyRun = unzoomCopy.tile.copySelection(); | ||
| 1585 | h.unzoom(); | ||
| 1586 | check('unzoom clears retained selection during copy', unzoomCopy.tile.selection, null); | ||
| 1587 | afterUnzoom.reject(new Error('late denial')); | ||
| 1588 | await unzoomCopyRun; | ||
| 1589 | check('late selection-copy failure after unzoom remains invisible', `${unzoomCopy.tile.copyButton.className}|${unzoomCopy.tile.copyButton.textContent}`, 'copy-request|Copy'); | ||
| 1590 | |||
| 1188 | const newSelectionUi = makeTile(); | 1591 | const newSelectionUi = makeTile(); |
| 1189 | newSelectionUi.tile.selection = { | 1592 | newSelectionUi.tile.selection = { |
| 1190 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 76, text: null, | 1593 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 76, text: null, |
| @@ -1211,6 +1614,8 @@ async function verifySelectionShell(shell, html) { | |||
| 1211 | anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 7, text: 'resolved old session', | 1614 | anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 7, text: 'resolved old session', |
| 1212 | }; | 1615 | }; |
| 1213 | reset.tile.drag = { pointerId: 15, moved: true }; | 1616 | reset.tile.drag = { pointerId: 15, moved: true }; |
| 1617 | reset.tile.scrollPages = 1; | ||
| 1618 | reset.tile.scrollRequest = { fromPages: 0, start: 25 }; | ||
| 1214 | reset.tile.canvas.setPointerCapture(15); | 1619 | reset.tile.canvas.setPointerCapture(15); |
| 1215 | reset.tile.selectionScrollTimer = h.context.setInterval(() => {}, 120); | 1620 | reset.tile.selectionScrollTimer = h.context.setInterval(() => {}, 120); |
| 1216 | const resetTimer = reset.tile.selectionScrollTimer; | 1621 | const resetTimer = reset.tile.selectionScrollTimer; |
| @@ -1219,6 +1624,7 @@ async function verifySelectionShell(shell, html) { | |||
| 1219 | check('destructive core reset clears drag state', reset.tile.drag, null); | 1624 | check('destructive core reset clears drag state', reset.tile.drag, null); |
| 1220 | check('destructive core reset clears pointer position', reset.tile.lastPointerY, null); | 1625 | check('destructive core reset clears pointer position', reset.tile.lastPointerY, null); |
| 1221 | check('destructive core reset stops selection timer', h.intervals[resetTimer - 1]?.cleared, true); | 1626 | check('destructive core reset stops selection timer', h.intervals[resetTimer - 1]?.cleared, true); |
| 1627 | check('destructive core reset clears orphaned scroll request', reset.tile.scrollRequest, null); | ||
| 1222 | 1628 | ||
| 1223 | const reconnect = makeTile(); | 1629 | const reconnect = makeTile(); |
| 1224 | reconnect.tile.canvas.dispatchEvent('pointerdown', pointer(16, 1, 1)); | 1630 | reconnect.tile.canvas.dispatchEvent('pointerdown', pointer(16, 1, 1)); |
| @@ -1291,12 +1697,14 @@ async function verifySelectionShell(shell, html) { | |||
| 1291 | failedResetPaint.tile.selection = { | 1697 | failedResetPaint.tile.selection = { |
| 1292 | anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 53, text: 'must disappear', | 1698 | anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 53, text: 'must disappear', |
| 1293 | }; | 1699 | }; |
| 1700 | failedResetPaint.tile.scrollRequest = { fromPages: 1, start: 20 }; | ||
| 1294 | failedResetPaint.tile.resetCore('failed paint safety test'); | 1701 | failedResetPaint.tile.resetCore('failed paint safety test'); |
| 1295 | check( | 1702 | check( |
| 1296 | 'failed destructive reset clears backing pixels without reading invalid core', | 1703 | 'failed destructive reset clears backing pixels without reading invalid core', |
| 1297 | failedResetOps.join('|'), | 1704 | failedResetOps.join('|'), |
| 1298 | 'transform:1,0,0,1,0,0|clear:0,0,640,480', | 1705 | 'transform:1,0,0,1,0,0|clear:0,0,640,480', |
| 1299 | ); | 1706 | ); |
| 1707 | check('failed destructive reset also clears orphaned scroll request', failedResetPaint.tile.scrollRequest, null); | ||
| 1300 | } | 1708 | } |
| 1301 | 1709 | ||
| 1302 | // --- wire builders (layouts golden-pinned in protocol.zig) --- | 1710 | // --- wire builders (layouts golden-pinned in protocol.zig) --- |