a73x

d0c694a2

feat: copy mouse selections in muxweb

a73x   2026-08-18 12:27

Commit message
feat: copy mouse selections in muxweb

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) ---