a73x

d18f2e2d

fix: keep IME focus when a copy control hides itself

a73x   2026-08-18 12:27

Commit message
fix: keep IME focus when a copy control hides itself

`.copy-request` is display:none without `.on`, and hiding a focused
element drops focus to <body>. A user who reached the control through
the Tab affordance but never activated it then loses the hidden IME
input, so compositionend stops firing and IME text is dead until a
click, with nothing on screen saying why. The three timer-driven hides
hand focus back; user-driven exits already did, in the click handler.

Also folds two smaller consistency debts in the same file: a paste in
history mode now returns the tile to live like every other input path
(without being swallowed — a paste carries content nobody would want
discarded), and the write-only `requestedViewStartRow` mirror is gone,
along with the four checks that read it back. What those gestured at is
held by the start/count echo match and the one-request-in-flight pin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

web/mux.js
Old New
@@ -144,7 +144,6 @@ class Tile {
144 this.copyUiOwner = null; // null, 'clipboard', or 'selection' 144 this.copyUiOwner = null; // null, 'clipboard', or 'selection'
145 this.selectionCopyVersion = 0; 145 this.selectionCopyVersion = 0;
146 this.viewStartRow = 0; 146 this.viewStartRow = 0;
147 this.requestedViewStartRow = 0;
148 this.scrollRequest = null; // {fromPages, start, count}; at most one in flight 147 this.scrollRequest = null; // {fromPages, start, count}; at most one in flight
149 this.scrollRequestTimer = null; 148 this.scrollRequestTimer = null;
150 this.selection = null; // {anchor, active, requestId, text} 149 this.selection = null; // {anchor, active, requestId, text}
@@ -527,7 +526,6 @@ class Tile {
527 this.clearScrollRequestTimer(); 526 this.clearScrollRequestTimer();
528 this.scrollRequest = null; 527 this.scrollRequest = null;
529 this.viewStartRow = start; 528 this.viewStartRow = start;
530 this.requestedViewStartRow = start;
531 this.moveDragToCurrentPointer(); 529 this.moveDragToCurrentPointer();
532 this.paintScroll(); 530 this.paintScroll();
533 } else this.scrollRequestFailed(request); 531 } else this.scrollRequestFailed(request);
@@ -582,6 +580,20 @@ class Tile {
582 this.copyButton.textContent = text; 580 this.copyButton.textContent = text;
583 } 581 }
584 582
583 // `.copy-request` is display:none without `.on`, and hiding a focused
584 // element drops focus to <body>. The hidden IME is what receives
585 // compositionend, so a tile that lost focus that way accepts no IME input
586 // at all until the user clicks — and nothing on screen says why. The
587 // paths that need this are the ones nobody asked for: the timers that
588 // retire feedback under a button the user tabbed to (mux.js's Tab
589 // affordance) and never activated. Every user-driven exit already hands
590 // focus back in the click handler and is left alone.
591 restoreImeFocusIfHidden() {
592 if (document.activeElement !== this.copyButton) return;
593 if (this.copyButton.classList.contains('on')) return;
594 ime.focus();
595 }
596
585 releaseSelectionCopyUi() { 597 releaseSelectionCopyUi() {
586 if (this.copyUiOwner !== 'selection') return; 598 if (this.copyUiOwner !== 'selection') return;
587 if (this.pendingClipboard !== null) { 599 if (this.pendingClipboard !== null) {
@@ -636,6 +648,7 @@ class Tile {
636 if (version !== this.clipboardVersion || this.pendingClipboard !== null || 648 if (version !== this.clipboardVersion || this.pendingClipboard !== null ||
637 this.copyUiOwner !== 'clipboard') return; 649 this.copyUiOwner !== 'clipboard') return;
638 this.renderCopyUi(null, 'copy-request', 'Copy'); 650 this.renderCopyUi(null, 'copy-request', 'Copy');
651 this.restoreImeFocusIfHidden();
639 }, 1200); 652 }, 1200);
640 } 653 }
641 654
@@ -689,6 +702,7 @@ class Tile {
689 if (!this.zoomed || this.selection !== selection || 702 if (!this.zoomed || this.selection !== selection ||
690 version !== this.selectionCopyVersion || this.copyUiOwner !== 'selection') return; 703 version !== this.selectionCopyVersion || this.copyUiOwner !== 'selection') return;
691 this.releaseSelectionCopyUi(); 704 this.releaseSelectionCopyUi();
705 this.restoreImeFocusIfHidden();
692 }, 1200); 706 }, 1200);
693 } 707 }
694 708
@@ -761,6 +775,14 @@ class Tile {
761 if (repaint && hadSelection && this.core) this.reflow(); 775 if (repaint && hadSelection && this.core) this.reflow();
762 } 776 }
763 777
778 // Nulling the handle and bumping the generation are ONE fact, not two:
779 // nothing revokes a selection request without doing both, in this one
780 // place. That makes the handle-identity comparisons downstream subsume
781 // the generation comparisons beside them — several of those conditions
782 // cannot fire on their own. They are deliberate belt-and-braces against a
783 // future second revoker that forgets half the act, and are not the
784 // invariant themselves. Do not "simplify" one away in isolation: the
785 // pairing here is what would have to change first.
764 invalidateSelectionRequest() { 786 invalidateSelectionRequest() {
765 if (this.selectionRequestTimer !== null) clearTimeout(this.selectionRequestTimer); 787 if (this.selectionRequestTimer !== null) clearTimeout(this.selectionRequestTimer);
766 this.selectionRequestTimer = null; 788 this.selectionRequestTimer = null;
@@ -785,6 +807,12 @@ class Tile {
785 return request; 807 return request;
786 } 808 }
787 809
810 // The two timers below compare setTimeout HANDLES by identity. Per the
811 // HTML spec an id is unique only among timers that are still active, so a
812 // handle can legitimately be reused once its predecessor has fired or
813 // been cleared. That is why the version fields exist beside it rather
814 // than instead of it: identity is the cheap first check, and the
815 // monotonic generation is what actually settles which request is meant.
788 startSelectionRequestTimeout(request) { 816 startSelectionRequestTimeout(request) {
789 const selection = request.selection; 817 const selection = request.selection;
790 const timer = setTimeout(() => { 818 const timer = setTimeout(() => {
@@ -818,6 +846,7 @@ class Tile {
818 this.selection !== selection || this.copyUiOwner !== 'selection') return; 846 this.selection !== selection || this.copyUiOwner !== 'selection') return;
819 this.selectionFeedbackVersion++; 847 this.selectionFeedbackVersion++;
820 this.releaseSelectionCopyUi(); 848 this.releaseSelectionCopyUi();
849 this.restoreImeFocusIfHidden();
821 }, 1200); 850 }, 1200);
822 this.selectionFeedbackTimer = timer; 851 this.selectionFeedbackTimer = timer;
823 } 852 }
@@ -985,7 +1014,6 @@ class Tile {
985 } 1014 }
986 paintLive() { 1015 paintLive() {
987 this.viewStartRow = this.core.mux_history_rows(); 1016 this.viewStartRow = this.core.mux_history_rows();
988 this.requestedViewStartRow = this.viewStartRow;
989 this.sizeCanvas(); 1017 this.sizeCanvas();
990 const n = this.core.mux_read_viewport(); 1018 const n = this.core.mux_read_viewport();
991 const paintedRows = new Set(); 1019 const paintedRows = new Set();
@@ -1095,7 +1123,6 @@ class Tile {
1095 this.scrollPages = next; 1123 this.scrollPages = next;
1096 this.renderBadge(); 1124 this.renderBadge();
1097 const start = this.core.mux_scroll_start(this.scrollPages, rows); 1125 const start = this.core.mux_scroll_start(this.scrollPages, rows);
1098 this.requestedViewStartRow = start;
1099 const request = { fromPages, start, count: rows }; 1126 const request = { fromPages, start, count: rows };
1100 this.scrollRequest = request; 1127 this.scrollRequest = request;
1101 const p = new Uint8Array(6); 1128 const p = new Uint8Array(6);
@@ -1130,7 +1157,6 @@ class Tile {
1130 this.clearScrollRequestTimer(); 1157 this.clearScrollRequestTimer();
1131 this.scrollRequest = null; 1158 this.scrollRequest = null;
1132 this.scrollPages = request.fromPages; 1159 this.scrollPages = request.fromPages;
1133 this.requestedViewStartRow = this.viewStartRow;
1134 this.renderBadge(); 1160 this.renderBadge();
1135 } 1161 }
1136 1162
@@ -1313,7 +1339,14 @@ document.addEventListener('paste', (ev) => {
1313 if (!zoomedTile) return; 1339 if (!zoomedTile) return;
1314 ev.preventDefault(); 1340 ev.preventDefault();
1315 const text = ev.clipboardData?.getData('text'); 1341 const text = ev.clipboardData?.getData('text');
1316 if (text) zoomedTile.sendPaste(text); 1342 if (!text) return;
1343 // Input returns the tile to live, exactly as every keystroke path does.
1344 // It is NOT swallowed the way a stray keystroke is: a paste is never
1345 // stray — it carries content the user explicitly asked to send, and
1346 // discarding it would cost them the copy as well as the page.
1347 zoomedTile.clearSelection(false);
1348 zoomedTile.exitScroll();
1349 zoomedTile.sendPaste(text);
1317 }); 1350 });
1318 ime.addEventListener('compositionend', (ev) => { 1351 ime.addEventListener('compositionend', (ev) => {
1319 // sendText, NOT sendPaste: composed text is typing (see sendText). 1352 // sendText, NOT sendPaste: composed text is typing (see sendText).
web/verify.js
Old New
@@ -378,7 +378,14 @@ function browserShell(source) {
378 } 378 }
379 return null; 379 return null;
380 } 380 }
381 addEventListener(type, fn) { this.listeners.set(type, fn); } 381 // Keyed by type, ONE listener each: the page registers exactly one per
382 // (element, type) today, and silently keeping only the last would mean
383 // a future double-registration was tested in name only.
384 addEventListener(type, fn) {
385 if (this.listeners.has(type))
386 throw new Error(`FakeElement: second ${type} listener on <${this.tagName}>`);
387 this.listeners.set(type, fn);
388 }
382 dispatchEvent(type, event) { return this.listeners.get(type)?.(event); } 389 dispatchEvent(type, event) { return this.listeners.get(type)?.(event); }
383 getBoundingClientRect() { return this.rect; } 390 getBoundingClientRect() { return this.rect; }
384 setPointerCapture(pointerId) { this.capturedPointers.add(pointerId); } 391 setPointerCapture(pointerId) { this.capturedPointers.add(pointerId); }
@@ -548,6 +555,17 @@ async function verifyClipboardShell(shell, html) {
548 check('automatic success makes feedback visible', snap.tile.copyButton.className, 'copy-request on'); 555 check('automatic success makes feedback visible', snap.tile.copyButton.className, 'copy-request on');
549 check('automatic success schedules 1200ms hide', h.timers.at(-1)?.ms, 1200); 556 check('automatic success schedules 1200ms hide', h.timers.at(-1)?.ms, 1200);
550 557
558 // The Tab affordance makes the copy control focusable, and the 1200ms
559 // timer then hides it. Focus would land on <body>, the hidden IME would
560 // stop seeing compositionend, and IME input would be dead until a click.
561 const hiddenUnderFocus = tileFor('hidden while focused');
562 h.navigator.clipboard = { writeText: () => Promise.resolve() };
563 await hiddenUnderFocus.tile.onClipboardEffect();
564 const hiddenUnderFocusTimer = h.timers.at(-1);
565 hiddenUnderFocus.tile.copyButton.focus();
566 hiddenUnderFocusTimer.fn();
567 check('hiding the focused copy control returns focus to the IME', h.document.activeElement, h.elements.ime);
568
551 const fallback = tileFor('retry me'); 569 const fallback = tileFor('retry me');
552 h.navigator.clipboard = undefined; 570 h.navigator.clipboard = undefined;
553 await fallback.tile.onClipboardEffect(); 571 await fallback.tile.onClipboardEffect();
@@ -762,7 +780,6 @@ async function verifySelectionShell(shell, html) {
762 && tile.selectionFeedbackVersion === 0 780 && tile.selectionFeedbackVersion === 0
763 && tile.selectionCopyVersion === 0; 781 && tile.selectionCopyVersion === 0;
764 check('tile initializes retained selection state', initialState, true); 782 check('tile initializes retained selection state', initialState, true);
765 check('tile initializes requested viewport state separately', tile.requestedViewStartRow, 0);
766 check('tile initializes shared copy-control ownership', tile.copyUiOwner, null); 783 check('tile initializes shared copy-control ownership', tile.copyUiOwner, null);
767 check( 784 check(
768 'tile binds all pointer selection events', 785 'tile binds all pointer selection events',
@@ -907,7 +924,6 @@ async function verifySelectionShell(shell, html) {
907 droppedScroll.tile.sendFrame = h.Tile.prototype.sendFrame.bind(droppedScroll.tile); 924 droppedScroll.tile.sendFrame = h.Tile.prototype.sendFrame.bind(droppedScroll.tile);
908 droppedScroll.tile.ws = { readyState: 0, send() { throw new Error('closed socket must not send'); } }; 925 droppedScroll.tile.ws = { readyState: 0, send() { throw new Error('closed socket must not send'); } };
909 droppedScroll.tile.viewStartRow = 30; 926 droppedScroll.tile.viewStartRow = 30;
910 droppedScroll.tile.requestedViewStartRow = 30;
911 check('closed-socket page request reports no movement', droppedScroll.tile.changeScrollPages(1), false); 927 check('closed-socket page request reports no movement', droppedScroll.tile.changeScrollPages(1), false);
912 check('closed-socket page request immediately rolls page intent back', droppedScroll.tile.scrollPages, 0); 928 check('closed-socket page request immediately rolls page intent back', droppedScroll.tile.scrollPages, 0);
913 check('closed-socket page request retains no pending request', droppedScroll.tile.scrollRequest, null); 929 check('closed-socket page request retains no pending request', droppedScroll.tile.scrollRequest, null);
@@ -915,7 +931,6 @@ async function verifySelectionShell(shell, html) {
915 931
916 const timeoutScroll = makeTile(); 932 const timeoutScroll = makeTile();
917 timeoutScroll.tile.viewStartRow = 30; 933 timeoutScroll.tile.viewStartRow = 30;
918 timeoutScroll.tile.requestedViewStartRow = 30;
919 timeoutScroll.tile.canvas.dispatchEvent('pointerdown', pointer(25, 2, 2)); 934 timeoutScroll.tile.canvas.dispatchEvent('pointerdown', pointer(25, 2, 2));
920 timeoutScroll.tile.canvas.dispatchEvent('pointermove', { 935 timeoutScroll.tile.canvas.dispatchEvent('pointermove', {
921 ...pointer(25, 4, 0), clientY: 10, 936 ...pointer(25, 4, 0), clientY: 10,
@@ -936,7 +951,6 @@ async function verifySelectionShell(shell, html) {
936 951
937 const staleTimeout = makeTile(); 952 const staleTimeout = makeTile();
938 staleTimeout.tile.viewStartRow = 30; 953 staleTimeout.tile.viewStartRow = 30;
939 staleTimeout.tile.requestedViewStartRow = 30;
940 staleTimeout.tile.paintScroll = () => {}; 954 staleTimeout.tile.paintScroll = () => {};
941 staleTimeout.tile.changeScrollPages(1); 955 staleTimeout.tile.changeScrollPages(1);
942 const firstTimeout = h.timers[staleTimeout.tile.scrollRequestTimer - 1]; 956 const firstTimeout = h.timers[staleTimeout.tile.scrollRequestTimer - 1];
@@ -950,7 +964,6 @@ async function verifySelectionShell(shell, html) {
950 964
951 const lifecycleScroll = makeTile(); 965 const lifecycleScroll = makeTile();
952 lifecycleScroll.tile.viewStartRow = 30; 966 lifecycleScroll.tile.viewStartRow = 30;
953 lifecycleScroll.tile.requestedViewStartRow = 30;
954 lifecycleScroll.tile.connect(); 967 lifecycleScroll.tile.connect();
955 lifecycleScroll.tile.changeScrollPages(1); 968 lifecycleScroll.tile.changeScrollPages(1);
956 const closeTimeout = h.timers[lifecycleScroll.tile.scrollRequestTimer - 1]; 969 const closeTimeout = h.timers[lifecycleScroll.tile.scrollRequestTimer - 1];
@@ -965,7 +978,6 @@ async function verifySelectionShell(shell, html) {
965 978
966 const attachScroll = makeTile(); 979 const attachScroll = makeTile();
967 attachScroll.tile.viewStartRow = 30; 980 attachScroll.tile.viewStartRow = 30;
968 attachScroll.tile.requestedViewStartRow = 30;
969 attachScroll.tile.changeScrollPages(1); 981 attachScroll.tile.changeScrollPages(1);
970 const attachTimeout = h.timers[attachScroll.tile.scrollRequestTimer - 1]; 982 const attachTimeout = h.timers[attachScroll.tile.scrollRequestTimer - 1];
971 attachScroll.tile.sendAttach(false); 983 attachScroll.tile.sendAttach(false);
@@ -974,7 +986,6 @@ async function verifySelectionShell(shell, html) {
974 986
975 const controlLifecycle = makeTile(); 987 const controlLifecycle = makeTile();
976 controlLifecycle.tile.viewStartRow = 30; 988 controlLifecycle.tile.viewStartRow = 30;
977 controlLifecycle.tile.requestedViewStartRow = 30;
978 controlLifecycle.tile.changeScrollPages(1); 989 controlLifecycle.tile.changeScrollPages(1);
979 const controlTimeout = h.timers[controlLifecycle.tile.scrollRequestTimer - 1]; 990 const controlTimeout = h.timers[controlLifecycle.tile.scrollRequestTimer - 1];
980 const reconnectingControl = Uint8Array.from([ 991 const reconnectingControl = Uint8Array.from([
@@ -986,7 +997,6 @@ async function verifySelectionShell(shell, html) {
986 997
987 const exitLifecycle = makeTile(); 998 const exitLifecycle = makeTile();
988 exitLifecycle.tile.viewStartRow = 30; 999 exitLifecycle.tile.viewStartRow = 30;
989 exitLifecycle.tile.requestedViewStartRow = 30;
990 exitLifecycle.tile.changeScrollPages(1); 1000 exitLifecycle.tile.changeScrollPages(1);
991 const exitTimeout = h.timers[exitLifecycle.tile.scrollRequestTimer - 1]; 1001 const exitTimeout = h.timers[exitLifecycle.tile.scrollRequestTimer - 1];
992 exitLifecycle.tile.onMessage(Uint8Array.from([0, 0x82, 1, 0, 0, 0, 0])); 1002 exitLifecycle.tile.onMessage(Uint8Array.from([0, 0x82, 1, 0, 0, 0, 0]));
@@ -1009,7 +1019,6 @@ async function verifySelectionShell(shell, html) {
1009 1019
1010 const countMismatch = makeTile(); 1020 const countMismatch = makeTile();
1011 countMismatch.tile.viewStartRow = 30; 1021 countMismatch.tile.viewStartRow = 30;
1012 countMismatch.tile.requestedViewStartRow = 30;
1013 let countPaints = 0; 1022 let countPaints = 0;
1014 countMismatch.tile.paintScroll = () => { countPaints++; }; 1023 countMismatch.tile.paintScroll = () => { countPaints++; };
1015 countMismatch.tile.changeScrollPages(1); 1024 countMismatch.tile.changeScrollPages(1);
@@ -1021,7 +1030,6 @@ async function verifySelectionShell(shell, html) {
1021 1030
1022 const resizeMismatch = makeTile(); 1031 const resizeMismatch = makeTile();
1023 resizeMismatch.tile.viewStartRow = 30; 1032 resizeMismatch.tile.viewStartRow = 30;
1024 resizeMismatch.tile.requestedViewStartRow = 30;
1025 resizeMismatch.tile.changeScrollPages(1); 1033 resizeMismatch.tile.changeScrollPages(1);
1026 const resizeTimeout = h.timers[resizeMismatch.tile.scrollRequestTimer - 1]; 1034 const resizeTimeout = h.timers[resizeMismatch.tile.scrollRequestTimer - 1];
1027 resizeMismatch.tile.zoomCols = () => 11; 1035 resizeMismatch.tile.zoomCols = () => 11;
@@ -1041,7 +1049,6 @@ async function verifySelectionShell(shell, html) {
1041 }; 1049 };
1042 snapshotHistory.tile.scrollPages = 2; 1050 snapshotHistory.tile.scrollPages = 2;
1043 snapshotHistory.tile.viewStartRow = 20; 1051 snapshotHistory.tile.viewStartRow = 20;
1044 snapshotHistory.tile.requestedViewStartRow = 20;
1045 snapshotHistory.tile.selection = { 1052 snapshotHistory.tile.selection = {
1046 anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 90, text: 'old grid', 1053 anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 90, text: 'old grid',
1047 }; 1054 };
@@ -1062,7 +1069,6 @@ async function verifySelectionShell(shell, html) {
1062 check('authoritative snapshot repaints new live geometry exactly once', snapshotLivePaints, 1); 1069 check('authoritative snapshot repaints new live geometry exactly once', snapshotLivePaints, 1);
1063 check('authoritative snapshot never repaints stale history scratch', snapshotScrollPaints, 0); 1070 check('authoritative snapshot never repaints stale history scratch', snapshotScrollPaints, 0);
1064 check('snapshot live repaint adopts new history start', snapshotHistory.tile.viewStartRow, 40); 1071 check('snapshot live repaint adopts new history start', snapshotHistory.tile.viewStartRow, 40);
1065 check('snapshot live repaint synchronizes requested start', snapshotHistory.tile.requestedViewStartRow, 40);
1066 check('snapshot leaves no scroll request timer', snapshotHistory.tile.scrollRequestTimer, null); 1072 check('snapshot leaves no scroll request timer', snapshotHistory.tile.scrollRequestTimer, null);
1067 check('snapshot clears old-grid selection response timeout', snapshotSelectionTimer?.cleared, true); 1073 check('snapshot clears old-grid selection response timeout', snapshotSelectionTimer?.cleared, true);
1068 check('snapshot leaves no selection response timer identity', snapshotHistory.tile.selectionRequestTimer, null); 1074 check('snapshot leaves no selection response timer identity', snapshotHistory.tile.selectionRequestTimer, null);
@@ -1076,7 +1082,6 @@ async function verifySelectionShell(shell, html) {
1076 const failedSnapshot = makeTile(); 1082 const failedSnapshot = makeTile();
1077 failedSnapshot.tile.scrollPages = 2; 1083 failedSnapshot.tile.scrollPages = 2;
1078 failedSnapshot.tile.viewStartRow = 20; 1084 failedSnapshot.tile.viewStartRow = 20;
1079 failedSnapshot.tile.requestedViewStartRow = 20;
1080 failedSnapshot.tile.selection = { 1085 failedSnapshot.tile.selection = {
1081 anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 91, text: 'failed old grid', 1086 anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 91, text: 'failed old grid',
1082 }; 1087 };
@@ -1231,7 +1236,6 @@ async function verifySelectionShell(shell, html) {
1231 scrolling.tile.viewStartRow = 30; 1236 scrolling.tile.viewStartRow = 30;
1232 scrolling.tile.onWheel({ deltaY: -1 }); 1237 scrolling.tile.onWheel({ deltaY: -1 });
1233 check('wheel request preserves the currently painted start row', scrolling.tile.viewStartRow, 30); 1238 check('wheel request preserves the currently painted start row', scrolling.tile.viewStartRow, 30);
1234 check('wheel retains the separately requested start row', scrolling.tile.requestedViewStartRow, 25);
1235 check('wheel sends scrollback fetch', scrolling.sent[0]?.type, 0x05); 1239 check('wheel sends scrollback fetch', scrolling.sent[0]?.type, 0x05);
1236 scrolling.tile.canvas.dispatchEvent('pointerdown', pointer(13, 2, 2)); 1240 scrolling.tile.canvas.dispatchEvent('pointerdown', pointer(13, 2, 2));
1237 check('quick drag before history reply maps against painted rows', scrolling.tile.selection.anchor.row, 32); 1241 check('quick drag before history reply maps against painted rows', scrolling.tile.selection.anchor.row, 32);
@@ -1282,7 +1286,6 @@ async function verifySelectionShell(shell, html) {
1282 1286
1283 const auto = makeTile(); 1287 const auto = makeTile();
1284 auto.tile.viewStartRow = 30; 1288 auto.tile.viewStartRow = 30;
1285 auto.tile.requestedViewStartRow = 30;
1286 let autoPaints = 0; 1289 let autoPaints = 0;
1287 auto.tile.paintScroll = () => { autoPaints++; }; 1290 auto.tile.paintScroll = () => { autoPaints++; };
1288 auto.tile.canvas.dispatchEvent('pointerdown', pointer(18, 2, 2)); 1291 auto.tile.canvas.dispatchEvent('pointerdown', pointer(18, 2, 2));
@@ -1295,7 +1298,6 @@ async function verifySelectionShell(shell, html) {
1295 check('auto-scroll uses fetch-scrollback wire type', auto.sent[0]?.type, 0x05); 1298 check('auto-scroll uses fetch-scrollback wire type', auto.sent[0]?.type, 0x05);
1296 check('auto-scroll keeps coordinates tied to painted page while pending', auto.tile.selection.active.row, 30); 1299 check('auto-scroll keeps coordinates tied to painted page while pending', auto.tile.selection.active.row, 30);
1297 check('auto-scroll keeps painted start unchanged while pending', auto.tile.viewStartRow, 30); 1300 check('auto-scroll keeps painted start unchanged while pending', auto.tile.viewStartRow, 30);
1298 check('auto-scroll records requested page separately', auto.tile.requestedViewStartRow, 25);
1299 autoTimer.fn(); 1301 autoTimer.fn();
1300 check('auto-scroll does not stack requests while a paint is pending', auto.sent.length, 1); 1302 check('auto-scroll does not stack requests while a paint is pending', auto.sent.length, 1);
1301 auto.tile.onMessage(scrollEnvelope(20)); 1303 auto.tile.onMessage(scrollEnvelope(20));
@@ -1319,7 +1321,6 @@ async function verifySelectionShell(shell, html) {
1319 1321
1320 const insideDrift = makeTile(); 1322 const insideDrift = makeTile();
1321 insideDrift.tile.viewStartRow = 30; 1323 insideDrift.tile.viewStartRow = 30;
1322 insideDrift.tile.requestedViewStartRow = 30;
1323 insideDrift.tile.paintScroll = () => {}; 1324 insideDrift.tile.paintScroll = () => {};
1324 insideDrift.tile.canvas.dispatchEvent('pointerdown', pointer(26, 2, 2)); 1325 insideDrift.tile.canvas.dispatchEvent('pointerdown', pointer(26, 2, 2));
1325 insideDrift.tile.canvas.dispatchEvent('pointermove', { 1326 insideDrift.tile.canvas.dispatchEvent('pointermove', {
@@ -1335,7 +1336,6 @@ async function verifySelectionShell(shell, html) {
1335 1336
1336 const outsideXDrift = makeTile(); 1337 const outsideXDrift = makeTile();
1337 outsideXDrift.tile.viewStartRow = 30; 1338 outsideXDrift.tile.viewStartRow = 30;
1338 outsideXDrift.tile.requestedViewStartRow = 30;
1339 outsideXDrift.tile.paintScroll = () => {}; 1339 outsideXDrift.tile.paintScroll = () => {};
1340 outsideXDrift.tile.canvas.dispatchEvent('pointerdown', pointer(27, 2, 2)); 1340 outsideXDrift.tile.canvas.dispatchEvent('pointerdown', pointer(27, 2, 2));
1341 outsideXDrift.tile.canvas.dispatchEvent('pointermove', { 1341 outsideXDrift.tile.canvas.dispatchEvent('pointermove', {
@@ -1352,7 +1352,6 @@ async function verifySelectionShell(shell, html) {
1352 1352
1353 const failedAuto = makeTile(); 1353 const failedAuto = makeTile();
1354 failedAuto.tile.viewStartRow = 30; 1354 failedAuto.tile.viewStartRow = 30;
1355 failedAuto.tile.requestedViewStartRow = 30;
1356 failedAuto.tile.canvas.dispatchEvent('pointerdown', pointer(19, 2, 2)); 1355 failedAuto.tile.canvas.dispatchEvent('pointerdown', pointer(19, 2, 2));
1357 failedAuto.tile.canvas.dispatchEvent('pointermove', { 1356 failedAuto.tile.canvas.dispatchEvent('pointermove', {
1358 ...pointer(19, 4, 0), clientY: 10, 1357 ...pointer(19, 4, 0), clientY: 10,
@@ -1378,7 +1377,6 @@ async function verifySelectionShell(shell, html) {
1378 1377
1379 const failedAutoStage = makeTile(); 1378 const failedAutoStage = makeTile();
1380 failedAutoStage.tile.viewStartRow = 30; 1379 failedAutoStage.tile.viewStartRow = 30;
1381 failedAutoStage.tile.requestedViewStartRow = 30;
1382 failedAutoStage.tile.canvas.dispatchEvent('pointerdown', pointer(22, 2, 2)); 1380 failedAutoStage.tile.canvas.dispatchEvent('pointerdown', pointer(22, 2, 2));
1383 failedAutoStage.tile.canvas.dispatchEvent('pointermove', { 1381 failedAutoStage.tile.canvas.dispatchEvent('pointermove', {
1384 ...pointer(22, 4, 0), clientY: 10, 1382 ...pointer(22, 4, 0), clientY: 10,
@@ -1398,7 +1396,6 @@ async function verifySelectionShell(shell, html) {
1398 1396
1399 const malformedAuto = makeTile(); 1397 const malformedAuto = makeTile();
1400 malformedAuto.tile.viewStartRow = 30; 1398 malformedAuto.tile.viewStartRow = 30;
1401 malformedAuto.tile.requestedViewStartRow = 30;
1402 malformedAuto.tile.canvas.dispatchEvent('pointerdown', pointer(24, 2, 2)); 1399 malformedAuto.tile.canvas.dispatchEvent('pointerdown', pointer(24, 2, 2));
1403 malformedAuto.tile.canvas.dispatchEvent('pointermove', { 1400 malformedAuto.tile.canvas.dispatchEvent('pointermove', {
1404 ...pointer(24, 4, 0), clientY: 10, 1401 ...pointer(24, 4, 0), clientY: 10,
@@ -1413,7 +1410,6 @@ async function verifySelectionShell(shell, html) {
1413 const edgeAuto = makeTile(); 1410 const edgeAuto = makeTile();
1414 edgeAuto.tile.scrollPages = 6; 1411 edgeAuto.tile.scrollPages = 6;
1415 edgeAuto.tile.viewStartRow = 0; 1412 edgeAuto.tile.viewStartRow = 0;
1416 edgeAuto.tile.requestedViewStartRow = 0;
1417 edgeAuto.tile.canvas.dispatchEvent('pointerdown', pointer(20, 3, 0)); 1413 edgeAuto.tile.canvas.dispatchEvent('pointerdown', pointer(20, 3, 0));
1418 edgeAuto.tile.canvas.dispatchEvent('pointermove', { 1414 edgeAuto.tile.canvas.dispatchEvent('pointermove', {
1419 ...pointer(20, 3, 0), clientY: 10, 1415 ...pointer(20, 3, 0), clientY: 10,
@@ -1423,9 +1419,34 @@ async function verifySelectionShell(shell, html) {
1423 check('bounded auto-scroll retains painted endpoint', edgeAuto.tile.selection.active.row, 0); 1419 check('bounded auto-scroll retains painted endpoint', edgeAuto.tile.selection.active.row, 0);
1424 edgeAuto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 20 }); 1420 edgeAuto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 20 });
1425 1421
1422 // A drag that leaves the canvas at the very top never changes cell —
1423 // cellAtPointer clamps to view row 0 — so `moved` can only become true
1424 // when auto-scroll remaps the endpoint onto a genuinely different
1425 // absolute row. Without that remap setting the flag, pointerup takes the
1426 // plain-click branch and throws a multi-page selection away.
1427 const clampedDrag = makeTile();
1428 clampedDrag.tile.viewStartRow = 30;
1429 clampedDrag.tile.paintScroll = () => {};
1430 clampedDrag.tile.canvas.dispatchEvent('pointerdown', pointer(44, 2, 0));
1431 clampedDrag.tile.canvas.dispatchEvent('pointermove', {
1432 ...pointer(44, 2, 0), clientY: 10,
1433 });
1434 check('a drag clamped at the top edge has not moved a cell yet', clampedDrag.tile.selection.active.row, 30);
1435 h.intervals[clampedDrag.tile.selectionScrollTimer - 1].fn();
1436 clampedDrag.tile.onMessage(scrollEnvelope(25));
1437 check('the painted older page remaps the clamped endpoint', clampedDrag.tile.selection.active.row, 25);
1438 clampedDrag.tile.canvas.dispatchEvent('pointerup', {
1439 ...pointer(44, 2, 0), clientY: 10,
1440 });
1441 check('a page-crossing drag is not discarded as a plain click', clampedDrag.tile.selection !== null, true);
1442 check(
1443 'a page-crossing drag requests the rows it actually spanned',
1444 JSON.stringify(clampedDrag.requestCalls),
1445 JSON.stringify([[1, 30, 2, 25, 2]]),
1446 );
1447
1426 const releaseRace = makeTile(); 1448 const releaseRace = makeTile();
1427 releaseRace.tile.viewStartRow = 30; 1449 releaseRace.tile.viewStartRow = 30;
1428 releaseRace.tile.requestedViewStartRow = 30;
1429 let releasePaints = 0; 1450 let releasePaints = 0;
1430 releaseRace.tile.paintScroll = () => { releasePaints++; }; 1451 releaseRace.tile.paintScroll = () => { releasePaints++; };
1431 releaseRace.tile.canvas.dispatchEvent('pointerdown', pointer(21, 2, 2)); 1452 releaseRace.tile.canvas.dispatchEvent('pointerdown', pointer(21, 2, 2));
@@ -1446,7 +1467,6 @@ async function verifySelectionShell(shell, html) {
1446 1467
1447 const finalUp = makeTile(); 1468 const finalUp = makeTile();
1448 finalUp.tile.viewStartRow = 30; 1469 finalUp.tile.viewStartRow = 30;
1449 finalUp.tile.requestedViewStartRow = 30;
1450 finalUp.tile.canvas.dispatchEvent('pointerdown', pointer(28, 2, 1)); 1470 finalUp.tile.canvas.dispatchEvent('pointerdown', pointer(28, 2, 1));
1451 finalUp.tile.canvas.dispatchEvent('pointerup', pointer(28, 6, 4)); 1471 finalUp.tile.canvas.dispatchEvent('pointerup', pointer(28, 6, 4));
1452 check( 1472 check(
@@ -1458,7 +1478,6 @@ async function verifySelectionShell(shell, html) {
1458 1478
1459 const finalPendingUp = makeTile(); 1479 const finalPendingUp = makeTile();
1460 finalPendingUp.tile.viewStartRow = 30; 1480 finalPendingUp.tile.viewStartRow = 30;
1461 finalPendingUp.tile.requestedViewStartRow = 30;
1462 finalPendingUp.tile.paintScroll = () => {}; 1481 finalPendingUp.tile.paintScroll = () => {};
1463 finalPendingUp.tile.canvas.dispatchEvent('pointerdown', pointer(29, 2, 2)); 1482 finalPendingUp.tile.canvas.dispatchEvent('pointerdown', pointer(29, 2, 2));
1464 finalPendingUp.tile.canvas.dispatchEvent('pointermove', { 1483 finalPendingUp.tile.canvas.dispatchEvent('pointermove', {
@@ -1841,6 +1860,49 @@ async function verifySelectionShell(shell, html) {
1841 unzoomFeedbackTask?.fn(); 1860 unzoomFeedbackTask?.fn();
1842 check('stale unavailable timer remains invisible after unzoom', `${unzoomFeedback.tile.copyUiOwner}|${unzoomFeedback.tile.copyButton.className}|${unzoomFeedback.tile.copyButton.textContent}`, 'null|copy-request|Copy'); 1861 check('stale unavailable timer remains invisible after unzoom', `${unzoomFeedback.tile.copyUiOwner}|${unzoomFeedback.tile.copyButton.className}|${unzoomFeedback.tile.copyButton.textContent}`, 'null|copy-request|Copy');
1843 1862
1863 // The selection lane's two timer-driven hides, same hazard.
1864 const unavailableFocus = makeTile();
1865 unavailableFocus.tile.selection = {
1866 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 84, text: null,
1867 };
1868 authorizeSelectionReply(unavailableFocus);
1869 unavailableFocus.setResult(84, 3, []);
1870 unavailableFocus.tile.onSelectionReply();
1871 const unavailableFocusTimer = h.timers[unavailableFocus.tile.selectionFeedbackTimer - 1];
1872 unavailableFocus.tile.copyButton.focus();
1873 unavailableFocusTimer?.fn();
1874 check('expiring selection feedback under focus returns focus to the IME', h.document.activeElement, h.elements.ime);
1875
1876 // The other side of the guard: retiring selection feedback onto a pending
1877 // OSC 52 leaves the control VISIBLE and actionable, so focus must stay
1878 // where the user put it.
1879 const visibleAfterFeedback = makeTile();
1880 visibleAfterFeedback.tile.pendingClipboard = 'OSC52 still waiting';
1881 visibleAfterFeedback.tile.clipboardVersion = 1;
1882 visibleAfterFeedback.tile.copyUiOwner = 'clipboard';
1883 visibleAfterFeedback.tile.selection = {
1884 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 86, text: null,
1885 };
1886 authorizeSelectionReply(visibleAfterFeedback);
1887 visibleAfterFeedback.setResult(86, 3, []);
1888 visibleAfterFeedback.tile.onSelectionReply();
1889 const visibleFeedbackTimer = h.timers[visibleAfterFeedback.tile.selectionFeedbackTimer - 1];
1890 visibleAfterFeedback.tile.copyButton.focus();
1891 visibleFeedbackTimer?.fn();
1892 check('feedback expiring onto a still-visible retry keeps its focus', h.document.activeElement, visibleAfterFeedback.tile.copyButton);
1893 check('feedback expiring onto a still-visible retry stays actionable', `${visibleAfterFeedback.tile.copyButton.className}|${visibleAfterFeedback.tile.copyButton.textContent}`, 'copy-request on|Copy');
1894
1895 const copiedFocus = makeTile();
1896 copiedFocus.tile.selection = {
1897 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 85, text: 'copied then hidden',
1898 };
1899 h.navigator.clipboard = { writeText: () => Promise.resolve() };
1900 await copiedFocus.tile.copySelection();
1901 const copiedFocusTimer = h.timers.at(-1);
1902 copiedFocus.tile.copyButton.focus();
1903 copiedFocusTimer.fn();
1904 check('hiding the selection copy control under focus returns focus to the IME', h.document.activeElement, h.elements.ime);
1905
1844 const inFlightUi = makeTile(); 1906 const inFlightUi = makeTile();
1845 inFlightUi.tile.pendingClipboard = 'in flight'; 1907 inFlightUi.tile.pendingClipboard = 'in flight';
1846 inFlightUi.tile.clipboardVersion = 1; 1908 inFlightUi.tile.clipboardVersion = 1;
@@ -2056,7 +2118,6 @@ async function verifySelectionShell(shell, html) {
2056 const scrollExitKey = makeTile(); 2118 const scrollExitKey = makeTile();
2057 scrollExitKey.tile.scrollPages = 1; 2119 scrollExitKey.tile.scrollPages = 1;
2058 scrollExitKey.tile.viewStartRow = 25; 2120 scrollExitKey.tile.viewStartRow = 25;
2059 scrollExitKey.tile.requestedViewStartRow = 25;
2060 scrollExitKey.tile.selection = authoritativeSelection('history selection'); 2121 scrollExitKey.tile.selection = authoritativeSelection('history selection');
2061 h.setZoomedTile(scrollExitKey.tile); 2122 h.setZoomedTile(scrollExitKey.tile);
2062 const swallowedHistoryKey = keyEvent({ key: 'x' }); 2123 const swallowedHistoryKey = keyEvent({ key: 'x' });
@@ -2093,6 +2154,21 @@ async function verifySelectionShell(shell, html) {
2093 check('real paste handler clears retained selection through sendText', pasteClears.tile.selection, null); 2154 check('real paste handler clears retained selection through sendText', pasteClears.tile.selection, null);
2094 check('real paste handler emits one unwrapped text frame', pasteClears.sent.length, 1); 2155 check('real paste handler emits one unwrapped text frame', pasteClears.sent.length, 1);
2095 2156
2157 const pasteScroll = makeTile();
2158 pasteScroll.tile.scrollPages = 1;
2159 pasteScroll.tile.viewStartRow = 25;
2160 pasteScroll.tile.core.mux_paste_begin = () => 0;
2161 pasteScroll.tile.core.mux_paste_end = () => 0;
2162 pasteScroll.tile.core.mux_text_encode = (len) => len;
2163 pasteScroll.tile.core.mux_output_len = () => 1;
2164 h.setZoomedTile(pasteScroll.tile);
2165 h.document.dispatchEvent('paste', {
2166 preventDefault() {},
2167 clipboardData: { getData: () => 'pasted while paged back' },
2168 });
2169 check('paste returns the tile to live like every other input path', pasteScroll.tile.scrollPages, 0);
2170 check('paste out of history mode still delivers its content', pasteScroll.sent.length, 1);
2171
2096 const compositionClears = makeTile(); 2172 const compositionClears = makeTile();
2097 compositionClears.tile.core.mux_text_encode = (len) => len; 2173 compositionClears.tile.core.mux_text_encode = (len) => len;
2098 compositionClears.tile.core.mux_output_len = () => 1; 2174 compositionClears.tile.core.mux_output_len = () => 1;
@@ -2205,6 +2281,18 @@ async function verifySelectionShell(shell, html) {
2205 check('selection failure button retries the retained selection only', retryWrites.join('|'), 'retry exact selection'); 2281 check('selection failure button retries the retained selection only', retryWrites.join('|'), 'retry exact selection');
2206 check('selection retry click restores hidden IME focus', h.document.activeElement, h.elements.ime); 2282 check('selection retry click restores hidden IME focus', h.document.activeElement, h.elements.ime);
2207 2283
2284 // The same zoom gate onClipboardEffect and copyPendingClipboard carry:
2285 // only the tile the human is looking at may reach the system clipboard.
2286 const unzoomedSelectionCopy = makeTile();
2287 unzoomedSelectionCopy.tile.zoomed = false;
2288 unzoomedSelectionCopy.tile.selection = authoritativeSelection('wall tiles do not copy');
2289 const unzoomedSelectionWrites = [];
2290 h.navigator.clipboard = { writeText: (text) => { unzoomedSelectionWrites.push(text); return Promise.resolve(); } };
2291 await unzoomedSelectionCopy.tile.copySelection();
2292 check('an unzoomed tile writes no clipboard for its selection', unzoomedSelectionWrites.length, 0);
2293 check('an unzoomed selection copy claims no copy control', unzoomedSelectionCopy.tile.copyUiOwner, null);
2294 check('an unzoomed selection copy retains its text', unzoomedSelectionCopy.tile.selection?.text, 'wall tiles do not copy');
2295
2208 const invalidatedCopy = makeTile(); 2296 const invalidatedCopy = makeTile();
2209 invalidatedCopy.tile.selection = authoritativeSelection('old selection'); 2297 invalidatedCopy.tile.selection = authoritativeSelection('old selection');
2210 h.setZoomedTile(invalidatedCopy.tile); 2298 h.setZoomedTile(invalidatedCopy.tile);
@@ -2244,7 +2332,6 @@ async function verifySelectionShell(shell, html) {
2244 2332
2245 const unzoomScroll = makeTile(); 2333 const unzoomScroll = makeTile();
2246 unzoomScroll.tile.viewStartRow = 30; 2334 unzoomScroll.tile.viewStartRow = 30;
2247 unzoomScroll.tile.requestedViewStartRow = 30;
2248 unzoomScroll.tile.changeScrollPages(1); 2335 unzoomScroll.tile.changeScrollPages(1);
2249 const unzoomScrollTimeout = h.timers[unzoomScroll.tile.scrollRequestTimer - 1]; 2336 const unzoomScrollTimeout = h.timers[unzoomScroll.tile.scrollRequestTimer - 1];
2250 h.setZoomedTile(unzoomScroll.tile); 2337 h.setZoomedTile(unzoomScroll.tile);
@@ -2288,7 +2375,6 @@ async function verifySelectionShell(shell, html) {
2288 reset.tile.lastPointerX = 20; 2375 reset.tile.lastPointerX = 20;
2289 reset.tile.lastPointerY = 30; 2376 reset.tile.lastPointerY = 30;
2290 reset.tile.viewStartRow = 30; 2377 reset.tile.viewStartRow = 30;
2291 reset.tile.requestedViewStartRow = 30;
2292 reset.tile.changeScrollPages(1); 2378 reset.tile.changeScrollPages(1);
2293 const resetScrollTimeout = h.timers[reset.tile.scrollRequestTimer - 1]; 2379 const resetScrollTimeout = h.timers[reset.tile.scrollRequestTimer - 1];
2294 reset.tile.canvas.setPointerCapture(15); 2380 reset.tile.canvas.setPointerCapture(15);