168495eb
fix: harden web selection interaction races
a73x 2026-08-18 12:27
Commit message
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -32,6 +32,10 @@ const KEY = { | |||
| 32 | // Pastes chunk at 32 KiB so no browser→hub message approaches the hub's | 32 | // Pastes chunk at 32 KiB so no browser→hub message approaches the hub's |
| 33 | // 64 KiB inbound bound (webhub.zig ws_buffer_len). | 33 | // 64 KiB inbound bound (webhub.zig ws_buffer_len). |
| 34 | const PASTE_CHUNK = 32 * 1024; | 34 | const PASTE_CHUNK = 32 * 1024; |
| 35 | // A missing history reply must not permanently lock wheel/drag movement. | ||
| 36 | // Two seconds tolerates an ordinary remote round trip without letting the | ||
| 37 | // 120ms drag interval flood retries while one request is still outstanding. | ||
| 38 | const SCROLL_REQUEST_TIMEOUT_MS = 2000; | ||
| 35 | 39 | ||
| 36 | // The reconnect schedule, and it is NOT a new one: this is client.zig's | 40 | // The reconnect schedule, and it is NOT a new one: this is client.zig's |
| 37 | // nextBackoffMs (src/client.zig — "The reconnect pacing, M7's numbers") | 41 | // nextBackoffMs (src/client.zig — "The reconnect pacing, M7's numbers") |
| @@ -135,10 +139,12 @@ class Tile { | |||
| 135 | this.selectionCopyVersion = 0; | 139 | this.selectionCopyVersion = 0; |
| 136 | this.viewStartRow = 0; | 140 | this.viewStartRow = 0; |
| 137 | this.requestedViewStartRow = 0; | 141 | this.requestedViewStartRow = 0; |
| 138 | this.scrollRequest = null; // {fromPages, toPages, start}; at most one in flight | 142 | this.scrollRequest = null; // {fromPages, start, count}; at most one in flight |
| 143 | this.scrollRequestTimer = null; | ||
| 139 | this.selection = null; // {anchor, active, requestId, text} | 144 | this.selection = null; // {anchor, active, requestId, text} |
| 140 | this.nextSelectionId = 0; | 145 | this.nextSelectionId = 0; |
| 141 | this.drag = null; | 146 | this.drag = null; |
| 147 | this.lastPointerX = null; | ||
| 142 | this.lastPointerY = null; | 148 | this.lastPointerY = null; |
| 143 | this.selectionScrollTimer = null; | 149 | this.selectionScrollTimer = null; |
| 144 | 150 | ||
| @@ -203,6 +209,7 @@ class Tile { | |||
| 203 | this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); | 209 | this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); |
| 204 | this.ws.binaryType = 'arraybuffer'; | 210 | this.ws.binaryType = 'arraybuffer'; |
| 205 | this.ws.onopen = () => { | 211 | this.ws.onopen = () => { |
| 212 | this.cancelScrollRequest(); | ||
| 206 | this.clearSelection(); | 213 | this.clearSelection(); |
| 207 | this.wsOpened = true; | 214 | this.wsOpened = true; |
| 208 | this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule | 215 | this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule |
| @@ -219,6 +226,7 @@ class Tile { | |||
| 219 | // onerror always precedes onclose; the badge is decided in one place. | 226 | // onerror always precedes onclose; the badge is decided in one place. |
| 220 | this.ws.onerror = () => {}; | 227 | this.ws.onerror = () => {}; |
| 221 | this.ws.onclose = () => { | 228 | this.ws.onclose = () => { |
| 229 | this.cancelScrollRequest(); | ||
| 222 | this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1; | 230 | this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1; |
| 223 | const dead = this.wsFailures >= GONE_AFTER_FAILURES; | 231 | const dead = this.wsFailures >= GONE_AFTER_FAILURES; |
| 224 | this.setStatus(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting'); | 232 | this.setStatus(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting'); |
| @@ -263,7 +271,7 @@ class Tile { | |||
| 263 | // with a snapshot. | 271 | // with a snapshot. |
| 264 | resetCore(why) { | 272 | resetCore(why) { |
| 265 | this.clearSelection(false); | 273 | this.clearSelection(false); |
| 266 | this.scrollRequest = null; | 274 | this.cancelScrollRequest(); |
| 267 | this.scrollPages = 0; | 275 | this.scrollPages = 0; |
| 268 | if (this.core.mux_init(80, 24) !== 0) { | 276 | if (this.core.mux_init(80, 24) !== 0) { |
| 269 | this.clearBackingCanvas(); | 277 | this.clearBackingCanvas(); |
| @@ -292,13 +300,18 @@ class Tile { | |||
| 292 | 300 | ||
| 293 | // --- wire out --- | 301 | // --- wire out --- |
| 294 | sendFrame(type, payload) { | 302 | sendFrame(type, payload) { |
| 295 | if (this.ws.readyState !== WebSocket.OPEN) return; | 303 | if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false; |
| 296 | const msg = new Uint8Array(6 + payload.length); | 304 | const msg = new Uint8Array(6 + payload.length); |
| 297 | msg[0] = ENV_FRAME; | 305 | msg[0] = ENV_FRAME; |
| 298 | msg[1] = type; | 306 | msg[1] = type; |
| 299 | new DataView(msg.buffer).setUint32(2, payload.length, true); | 307 | new DataView(msg.buffer).setUint32(2, payload.length, true); |
| 300 | msg.set(payload, 6); | 308 | msg.set(payload, 6); |
| 301 | this.ws.send(msg); | 309 | try { |
| 310 | this.ws.send(msg); | ||
| 311 | return true; | ||
| 312 | } catch (_) { | ||
| 313 | return false; | ||
| 314 | } | ||
| 302 | } | 315 | } |
| 303 | sendAttach(fresh) { | 316 | sendAttach(fresh) { |
| 304 | // THE PASSIVITY CONTRACT (spec amendment 1): a wall tile attaches at | 317 | // THE PASSIVITY CONTRACT (spec amendment 1): a wall tile attaches at |
| @@ -309,6 +322,7 @@ class Tile { | |||
| 309 | // ONE gate for every attach, wherever it comes from — the `up` | 322 | // ONE gate for every attach, wherever it comes from — the `up` |
| 310 | // control message included. A tile that gave up on replaying must | 323 | // control message included. A tile that gave up on replaying must |
| 311 | // not be talked back into asking for the same frame again. | 324 | // not be talked back into asking for the same frame again. |
| 325 | this.cancelScrollRequest(); | ||
| 312 | if (this.replayDead) return; | 326 | if (this.replayDead) return; |
| 313 | this.clearSelection(); | 327 | this.clearSelection(); |
| 314 | const cols = this.zoomed ? this.zoomCols() : 1; | 328 | const cols = this.zoomed ? this.zoomCols() : 1; |
| @@ -382,6 +396,9 @@ class Tile { | |||
| 382 | if (!this.zoomed) return; | 396 | if (!this.zoomed) return; |
| 383 | const cols = this.zoomCols(), rows = this.zoomRows(); | 397 | const cols = this.zoomCols(), rows = this.zoomRows(); |
| 384 | if (cols === this.core.mux_cols() && rows === this.core.mux_rows()) return; | 398 | if (cols === this.core.mux_cols() && rows === this.core.mux_rows()) return; |
| 399 | // A scrollback reply encodes the grid row count sampled by its request. | ||
| 400 | // Never apply those bytes after changing the grid they describe. | ||
| 401 | this.cancelScrollRequest(); | ||
| 385 | const p = new Uint8Array(4); | 402 | const p = new Uint8Array(4); |
| 386 | const dv = new DataView(p.buffer); | 403 | const dv = new DataView(p.buffer); |
| 387 | dv.setUint16(0, cols, true); | 404 | dv.setUint16(0, cols, true); |
| @@ -411,6 +428,7 @@ class Tile { | |||
| 411 | // this tile: setStatus withholds the badge while the tile holds a | 428 | // this tile: setStatus withholds the badge while the tile holds a |
| 412 | // terminal state of its own. The bookkeeping below runs regardless | 429 | // terminal state of its own. The bookkeeping below runs regardless |
| 413 | // — sendAttach has its own gate (replayDead) and keeps it. | 430 | // — sendAttach has its own gate (replayDead) and keeps it. |
| 431 | if (state !== 'up') this.cancelScrollRequest(); | ||
| 414 | this.setStatus(state, state); | 432 | this.setStatus(state, state); |
| 415 | if (state === 'up') this.sendAttach(false); // browser owns re-attach | 433 | if (state === 'up') this.sendAttach(false); // browser owns re-attach |
| 416 | return; | 434 | return; |
| @@ -424,6 +442,7 @@ class Tile { | |||
| 424 | switch (type) { | 442 | switch (type) { |
| 425 | case MSG.snapshot: | 443 | case MSG.snapshot: |
| 426 | case MSG.delta: { | 444 | case MSG.delta: { |
| 445 | if (type === MSG.snapshot) this.cancelScrollRequest(); | ||
| 427 | // replica.zig's pinned subtlety, mirrored: a DELTA's arrival alone | 446 | // replica.zig's pinned subtlety, mirrored: a DELTA's arrival alone |
| 428 | // proves the attach was admitted — decodable or not — while a | 447 | // proves the attach was admitted — decodable or not — while a |
| 429 | // short snapshot proves nothing. Without this an undecodable | 448 | // short snapshot proves nothing. Without this an undecodable |
| @@ -456,6 +475,7 @@ class Tile { | |||
| 456 | return; | 475 | return; |
| 457 | } | 476 | } |
| 458 | case MSG.exit_status: { | 477 | case MSG.exit_status: { |
| 478 | this.cancelScrollRequest(); | ||
| 459 | // Before any state this is the daemon refusing the attach | 479 | // Before any state this is the daemon refusing the attach |
| 460 | // (session full) — the CLI's own discriminator, mirrored. | 480 | // (session full) — the CLI's own discriminator, mirrored. |
| 461 | if (!this.gotState) this.setStatus('full', 'session full'); | 481 | if (!this.gotState) this.setStatus('full', 'session full'); |
| @@ -468,21 +488,22 @@ class Tile { | |||
| 468 | if (this.scrollRequest) this.scrollRequestFailed(this.scrollRequest); | 488 | if (this.scrollRequest) this.scrollRequestFailed(this.scrollRequest); |
| 469 | return; | 489 | return; |
| 470 | } | 490 | } |
| 471 | const start = new DataView( | 491 | const echoed = new DataView(payload.buffer, payload.byteOffset, 6); |
| 472 | payload.buffer, payload.byteOffset, 6, | 492 | const start = echoed.getUint32(0, true); |
| 473 | ).getUint32(0, true); | 493 | const count = echoed.getUint16(4, true); |
| 474 | // A viewport becomes addressable by pointer coordinates only after | 494 | // A viewport becomes addressable by pointer coordinates only after |
| 475 | // the exact request for it decoded and painted. Delayed older wheel | 495 | // the exact request for it decoded and painted. Delayed older wheel |
| 476 | // replies must never relabel the cells currently on the canvas. | 496 | // replies must never relabel the cells currently on the canvas. |
| 477 | const request = this.scrollRequest; | 497 | const request = this.scrollRequest; |
| 478 | if (!request || start !== request.start) return; | 498 | if (!request || start !== request.start || count !== request.count) return; |
| 479 | const rows = payload.subarray(6); // echoed start+count stripped | 499 | const rows = payload.subarray(6); // echoed start+count stripped |
| 480 | if (!this.stage(rows)) { this.scrollRequestFailed(request); return; } | 500 | if (!this.stage(rows)) { this.scrollRequestFailed(request); return; } |
| 481 | if (this.core.mux_scroll_feed(rows.length) === 0) { | 501 | if (this.core.mux_scroll_feed(rows.length) === 0) { |
| 502 | this.clearScrollRequestTimer(); | ||
| 482 | this.scrollRequest = null; | 503 | this.scrollRequest = null; |
| 483 | this.viewStartRow = start; | 504 | this.viewStartRow = start; |
| 484 | this.requestedViewStartRow = start; | 505 | this.requestedViewStartRow = start; |
| 485 | this.moveDragToScrollBoundary(); | 506 | this.moveDragToCurrentPointer(); |
| 486 | this.paintScroll(); | 507 | this.paintScroll(); |
| 487 | } else this.scrollRequestFailed(request); | 508 | } else this.scrollRequestFailed(request); |
| 488 | return; | 509 | return; |
| @@ -603,6 +624,9 @@ class Tile { | |||
| 603 | try { | 624 | try { |
| 604 | // This call occurs synchronously before the first await, preserving | 625 | // This call occurs synchronously before the first await, preserving |
| 605 | // the key/click user activation required by the Clipboard API. | 626 | // the key/click user activation required by the Clipboard API. |
| 627 | // It cannot cancel an OSC52 write already handed to the browser, so | ||
| 628 | // those two calls may overlap. Versions order only our UI; the browser | ||
| 629 | // owns external clipboard completion ordering for the active calls. | ||
| 606 | await this.writeClipboardText(selection.text); | 630 | await this.writeClipboardText(selection.text); |
| 607 | succeeded = true; | 631 | succeeded = true; |
| 608 | } catch (_) { | 632 | } catch (_) { |
| @@ -676,6 +700,7 @@ class Tile { | |||
| 676 | stopSelectionDrag() { | 700 | stopSelectionDrag() { |
| 677 | const pointerId = this.drag?.pointerId; | 701 | const pointerId = this.drag?.pointerId; |
| 678 | this.drag = null; | 702 | this.drag = null; |
| 703 | this.lastPointerX = null; | ||
| 679 | this.lastPointerY = null; | 704 | this.lastPointerY = null; |
| 680 | if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer); | 705 | if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer); |
| 681 | this.selectionScrollTimer = null; | 706 | this.selectionScrollTimer = null; |
| @@ -692,6 +717,7 @@ class Tile { | |||
| 692 | this.clearSelection(false); | 717 | this.clearSelection(false); |
| 693 | this.selection = { anchor: point, active: point, requestId: 0, text: null }; | 718 | this.selection = { anchor: point, active: point, requestId: 0, text: null }; |
| 694 | this.drag = { pointerId: ev.pointerId, moved: false }; | 719 | this.drag = { pointerId: ev.pointerId, moved: false }; |
| 720 | this.lastPointerX = ev.clientX; | ||
| 695 | this.lastPointerY = ev.clientY; | 721 | this.lastPointerY = ev.clientY; |
| 696 | this.canvas.setPointerCapture(ev.pointerId); | 722 | this.canvas.setPointerCapture(ev.pointerId); |
| 697 | this.selectionScrollTimer = setInterval(() => this.autoScrollSelection(), 120); | 723 | this.selectionScrollTimer = setInterval(() => this.autoScrollSelection(), 120); |
| @@ -701,10 +727,12 @@ class Tile { | |||
| 701 | moveSelection(ev) { | 727 | moveSelection(ev) { |
| 702 | if (!this.drag || ev.pointerId !== this.drag.pointerId) return; | 728 | if (!this.drag || ev.pointerId !== this.drag.pointerId) return; |
| 703 | ev.preventDefault(); | 729 | ev.preventDefault(); |
| 730 | this.lastPointerX = ev.clientX; | ||
| 704 | this.lastPointerY = ev.clientY; | 731 | this.lastPointerY = ev.clientY; |
| 705 | const point = this.cellAtPointer(ev); | 732 | const point = this.cellAtPointer(ev); |
| 706 | if (point.row !== this.selection.active.row || point.col !== this.selection.active.col) | 733 | const endpointChanged = point.row !== this.selection.active.row || |
| 707 | this.drag.moved = true; | 734 | point.col !== this.selection.active.col; |
| 735 | if (endpointChanged) this.drag.moved = true; | ||
| 708 | this.selection.active = point; | 736 | this.selection.active = point; |
| 709 | this.reflow(); | 737 | this.reflow(); |
| 710 | } | 738 | } |
| @@ -712,6 +740,13 @@ class Tile { | |||
| 712 | endSelection(ev) { | 740 | endSelection(ev) { |
| 713 | if (!this.drag || ev.pointerId !== this.drag.pointerId) return; | 741 | if (!this.drag || ev.pointerId !== this.drag.pointerId) return; |
| 714 | ev.preventDefault(); | 742 | ev.preventDefault(); |
| 743 | this.lastPointerX = ev.clientX; | ||
| 744 | this.lastPointerY = ev.clientY; | ||
| 745 | const point = this.cellAtPointer(ev); | ||
| 746 | const endpointChanged = point.row !== this.selection.active.row || | ||
| 747 | point.col !== this.selection.active.col; | ||
| 748 | if (endpointChanged) this.drag.moved = true; | ||
| 749 | this.selection.active = point; | ||
| 715 | const moved = this.drag.moved; | 750 | const moved = this.drag.moved; |
| 716 | this.stopSelectionDrag(); | 751 | this.stopSelectionDrag(); |
| 717 | if (!moved) { this.clearSelection(); return; } | 752 | if (!moved) { this.clearSelection(); return; } |
| @@ -722,7 +757,10 @@ class Tile { | |||
| 722 | const n = this.core.mux_selection_request( | 757 | const n = this.core.mux_selection_request( |
| 723 | this.nextSelectionId, a.row, a.col, b.row, b.col, | 758 | this.nextSelectionId, a.row, a.col, b.row, b.col, |
| 724 | ); | 759 | ); |
| 725 | if (n === 16) this.sendFrame(MSG.selection_req, this.outBytes()); | 760 | if (n === 16) { |
| 761 | if (endpointChanged) this.reflow(); | ||
| 762 | this.sendFrame(MSG.selection_req, this.outBytes()); | ||
| 763 | } | ||
| 726 | else this.clearSelection(); | 764 | else this.clearSelection(); |
| 727 | } | 765 | } |
| 728 | 766 | ||
| @@ -733,16 +771,16 @@ class Tile { | |||
| 733 | (this.lastPointerY >= rect.bottom ? -1 : 0); | 771 | (this.lastPointerY >= rect.bottom ? -1 : 0); |
| 734 | } | 772 | } |
| 735 | 773 | ||
| 736 | moveDragToScrollBoundary() { | 774 | moveDragToCurrentPointer() { |
| 737 | const direction = this.pointerOutsideDirection(); | 775 | if (!this.drag || !this.selection || |
| 738 | if (direction === 0 || !this.selection) return false; | 776 | this.lastPointerX === null || this.lastPointerY === null) return false; |
| 739 | const viewRow = direction > 0 ? 0 : this.core.mux_rows() - 1; | 777 | const point = this.cellAtPointer({ |
| 740 | this.selection.active = { | 778 | clientX: this.lastPointerX, |
| 741 | row: this.viewStartRow + viewRow, | 779 | clientY: this.lastPointerY, |
| 742 | col: this.selection.active.col, | 780 | }); |
| 743 | viewRow, | 781 | if (point.row !== this.selection.active.row || point.col !== this.selection.active.col) |
| 744 | }; | 782 | this.drag.moved = true; |
| 745 | this.drag.moved = true; | 783 | this.selection.active = point; |
| 746 | return true; | 784 | return true; |
| 747 | } | 785 | } |
| 748 | 786 | ||
| @@ -755,7 +793,7 @@ class Tile { | |||
| 755 | // History requests update the endpoint only in scrollback_chunk after | 793 | // History requests update the endpoint only in scrollback_chunk after |
| 756 | // their matching bytes have decoded and become the painted viewport. | 794 | // their matching bytes have decoded and become the painted viewport. |
| 757 | if (!this.scrollRequest && this.viewStartRow !== paintedStart && | 795 | if (!this.scrollRequest && this.viewStartRow !== paintedStart && |
| 758 | this.moveDragToScrollBoundary()) this.reflow(); | 796 | this.moveDragToCurrentPointer()) this.reflow(); |
| 759 | } | 797 | } |
| 760 | 798 | ||
| 761 | // --- painting --- | 799 | // --- painting --- |
| @@ -924,17 +962,38 @@ class Tile { | |||
| 924 | this.renderBadge(); | 962 | this.renderBadge(); |
| 925 | const start = this.core.mux_scroll_start(this.scrollPages, rows); | 963 | const start = this.core.mux_scroll_start(this.scrollPages, rows); |
| 926 | this.requestedViewStartRow = start; | 964 | this.requestedViewStartRow = start; |
| 927 | this.scrollRequest = { fromPages, start }; | 965 | const request = { fromPages, start, count: rows }; |
| 966 | this.scrollRequest = request; | ||
| 928 | const p = new Uint8Array(6); | 967 | const p = new Uint8Array(6); |
| 929 | const dv = new DataView(p.buffer); | 968 | const dv = new DataView(p.buffer); |
| 930 | dv.setUint32(0, start, true); | 969 | dv.setUint32(0, start, true); |
| 931 | dv.setUint16(4, rows, true); | 970 | dv.setUint16(4, rows, true); |
| 932 | this.sendFrame(MSG.fetch_scrollback, p); | 971 | if (!this.sendFrame(MSG.fetch_scrollback, p)) { |
| 972 | this.scrollRequestFailed(request); | ||
| 973 | return false; | ||
| 974 | } | ||
| 975 | // WebSocket delivery is asynchronous in browsers, but keep the state | ||
| 976 | // transition correct for any host that can answer during send(). | ||
| 977 | if (this.scrollRequest !== request) return true; | ||
| 978 | this.scrollRequestTimer = setTimeout(() => { | ||
| 979 | if (this.scrollRequest === request) this.scrollRequestFailed(request); | ||
| 980 | }, SCROLL_REQUEST_TIMEOUT_MS); | ||
| 933 | return true; | 981 | return true; |
| 934 | } | 982 | } |
| 935 | 983 | ||
| 984 | clearScrollRequestTimer() { | ||
| 985 | if (this.scrollRequestTimer !== null) clearTimeout(this.scrollRequestTimer); | ||
| 986 | this.scrollRequestTimer = null; | ||
| 987 | } | ||
| 988 | |||
| 989 | cancelScrollRequest() { | ||
| 990 | if (this.scrollRequest) this.scrollRequestFailed(this.scrollRequest); | ||
| 991 | else this.clearScrollRequestTimer(); | ||
| 992 | } | ||
| 993 | |||
| 936 | scrollRequestFailed(request) { | 994 | scrollRequestFailed(request) { |
| 937 | if (this.scrollRequest !== request) return; | 995 | if (this.scrollRequest !== request) return; |
| 996 | this.clearScrollRequestTimer(); | ||
| 938 | this.scrollRequest = null; | 997 | this.scrollRequest = null; |
| 939 | this.scrollPages = request.fromPages; | 998 | this.scrollPages = request.fromPages; |
| 940 | this.requestedViewStartRow = this.viewStartRow; | 999 | this.requestedViewStartRow = this.viewStartRow; |
| @@ -946,7 +1005,8 @@ class Tile { | |||
| 946 | } | 1005 | } |
| 947 | 1006 | ||
| 948 | exitScroll() { | 1007 | exitScroll() { |
| 949 | if (this.scrollPages === 0) return; | 1008 | this.clearScrollRequestTimer(); |
| 1009 | if (this.scrollPages === 0) { this.scrollRequest = null; return; } | ||
| 950 | this.scrollRequest = null; | 1010 | this.scrollRequest = null; |
| 951 | this.scrollPages = 0; | 1011 | this.scrollPages = 0; |
| 952 | this.renderBadge(); | 1012 | this.renderBadge(); |
| @@ -1068,7 +1128,11 @@ document.addEventListener('keydown', (ev) => { | |||
| 1068 | // to make the recovery control keyboard reachable. | 1128 | // to make the recovery control keyboard reachable. |
| 1069 | if (ev.target === ime && ev.key === 'Tab' && | 1129 | if (ev.target === ime && ev.key === 'Tab' && |
| 1070 | !ev.shiftKey && !ev.altKey && !ev.ctrlKey && !ev.metaKey && | 1130 | !ev.shiftKey && !ev.altKey && !ev.ctrlKey && !ev.metaKey && |
| 1071 | t.pendingClipboard !== null && t.copyButton.classList.contains('on')) { | 1131 | t.copyButton.classList.contains('on') && ( |
| 1132 | (t.copyUiOwner === 'clipboard' && t.pendingClipboard !== null) || | ||
| 1133 | (t.copyUiOwner === 'selection' && t.selection?.text !== null && | ||
| 1134 | t.selection?.text !== undefined && t.copyButton.classList.contains('error')) | ||
| 1135 | )) { | ||
| 1072 | ev.preventDefault(); | 1136 | ev.preventDefault(); |
| 1073 | t.copyButton.focus(); | 1137 | t.copyButton.focus(); |
| 1074 | return; | 1138 | return; |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -417,7 +417,8 @@ function browserShell(source) { | |||
| 417 | ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder, | 417 | ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder, |
| 418 | Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document, | 418 | Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document, |
| 419 | location: { host: 'verify.invalid' }, navigator, | 419 | location: { host: 'verify.invalid' }, navigator, |
| 420 | setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, | 420 | setTimeout: (fn, ms) => { timers.push({ fn, ms, cleared: false }); return timers.length; }, |
| 421 | clearTimeout: (id) => { if (timers[id - 1]) timers[id - 1].cleared = true; }, | ||
| 421 | setInterval: (fn, ms) => { intervals.push({ fn, ms, cleared: false }); return intervals.length; }, | 422 | setInterval: (fn, ms) => { intervals.push({ fn, ms, cleared: false }); return intervals.length; }, |
| 422 | clearInterval: (id) => { if (intervals[id - 1]) intervals[id - 1].cleared = true; }, | 423 | clearInterval: (id) => { if (intervals[id - 1]) intervals[id - 1].cleared = true; }, |
| 423 | window: { addEventListener() {}, devicePixelRatio: 1 }, | 424 | window: { addEventListener() {}, devicePixelRatio: 1 }, |
| @@ -722,9 +723,11 @@ async function verifySelectionShell(shell, html) { | |||
| 722 | && tile.selection === null | 723 | && tile.selection === null |
| 723 | && tile.nextSelectionId === 0 | 724 | && tile.nextSelectionId === 0 |
| 724 | && tile.drag === null | 725 | && tile.drag === null |
| 726 | && tile.lastPointerX === null | ||
| 725 | && tile.lastPointerY === null | 727 | && tile.lastPointerY === null |
| 726 | && tile.selectionScrollTimer === null | 728 | && tile.selectionScrollTimer === null |
| 727 | && tile.scrollRequest === null | 729 | && tile.scrollRequest === null |
| 730 | && tile.scrollRequestTimer === null | ||
| 728 | && tile.selectionCopyVersion === 0; | 731 | && tile.selectionCopyVersion === 0; |
| 729 | check('tile initializes retained selection state', initialState, true); | 732 | check('tile initializes retained selection state', initialState, true); |
| 730 | check('tile initializes requested viewport state separately', tile.requestedViewStartRow, 0); | 733 | check('tile initializes requested viewport state separately', tile.requestedViewStartRow, 0); |
| @@ -804,7 +807,10 @@ async function verifySelectionShell(shell, html) { | |||
| 804 | selected.drawScale = 1; | 807 | selected.drawScale = 1; |
| 805 | selected.canvas.rect = { left: 10, top: 20, width: 80, height: 70 }; | 808 | selected.canvas.rect = { left: 10, top: 20, width: 80, height: 70 }; |
| 806 | const sent = []; | 809 | const sent = []; |
| 807 | selected.sendFrame = (type, payload) => sent.push({ type, payload: Uint8Array.from(payload) }); | 810 | selected.sendFrame = (type, payload) => { |
| 811 | sent.push({ type, payload: Uint8Array.from(payload) }); | ||
| 812 | return true; | ||
| 813 | }; | ||
| 808 | let reflows = 0; | 814 | let reflows = 0; |
| 809 | selected.reflow = () => { reflows++; }; | 815 | selected.reflow = () => { reflows++; }; |
| 810 | return { | 816 | return { |
| @@ -842,6 +848,128 @@ async function verifySelectionShell(shell, html) { | |||
| 842 | return envelope; | 848 | return envelope; |
| 843 | }; | 849 | }; |
| 844 | 850 | ||
| 851 | const transport = makeTile(); | ||
| 852 | transport.tile.sendFrame = h.Tile.prototype.sendFrame.bind(transport.tile); | ||
| 853 | const transportWrites = []; | ||
| 854 | transport.tile.ws = { readyState: 0, send: (bytes) => transportWrites.push(bytes) }; | ||
| 855 | check('sendFrame reports a closed-socket drop', transport.tile.sendFrame(0x05, new Uint8Array()), false); | ||
| 856 | transport.tile.ws.readyState = 1; | ||
| 857 | check('sendFrame reports an open-socket send', transport.tile.sendFrame(0x05, new Uint8Array()), true); | ||
| 858 | check('open sendFrame writes exactly one envelope', transportWrites.length, 1); | ||
| 859 | |||
| 860 | const droppedScroll = makeTile(); | ||
| 861 | droppedScroll.tile.sendFrame = h.Tile.prototype.sendFrame.bind(droppedScroll.tile); | ||
| 862 | droppedScroll.tile.ws = { readyState: 0, send() { throw new Error('closed socket must not send'); } }; | ||
| 863 | droppedScroll.tile.viewStartRow = 30; | ||
| 864 | droppedScroll.tile.requestedViewStartRow = 30; | ||
| 865 | check('closed-socket page request reports no movement', droppedScroll.tile.changeScrollPages(1), false); | ||
| 866 | check('closed-socket page request immediately rolls page intent back', droppedScroll.tile.scrollPages, 0); | ||
| 867 | check('closed-socket page request retains no pending request', droppedScroll.tile.scrollRequest, null); | ||
| 868 | check('closed-socket page request starts no response timeout', droppedScroll.tile.scrollRequestTimer, null); | ||
| 869 | |||
| 870 | const timeoutScroll = makeTile(); | ||
| 871 | timeoutScroll.tile.viewStartRow = 30; | ||
| 872 | timeoutScroll.tile.requestedViewStartRow = 30; | ||
| 873 | timeoutScroll.tile.canvas.dispatchEvent('pointerdown', pointer(25, 2, 2)); | ||
| 874 | timeoutScroll.tile.canvas.dispatchEvent('pointermove', { | ||
| 875 | ...pointer(25, 4, 0), clientY: 10, | ||
| 876 | }); | ||
| 877 | const timeoutAutoTimer = h.intervals[timeoutScroll.tile.selectionScrollTimer - 1]; | ||
| 878 | timeoutAutoTimer.fn(); | ||
| 879 | const responseTimeout = h.timers[timeoutScroll.tile.scrollRequestTimer - 1]; | ||
| 880 | check('scroll request timeout is bounded to 2000ms', responseTimeout?.ms, 2000); | ||
| 881 | timeoutAutoTimer.fn(); | ||
| 882 | check('120ms auto-scroll timer does not retry before response timeout', timeoutScroll.sent.length, 1); | ||
| 883 | responseTimeout.fn(); | ||
| 884 | check('silent scroll server timeout rolls page intent back', timeoutScroll.tile.scrollPages, 0); | ||
| 885 | check('silent scroll server timeout releases pending request', timeoutScroll.tile.scrollRequest, null); | ||
| 886 | check('silent scroll server timeout clears timer identity', timeoutScroll.tile.scrollRequestTimer, null); | ||
| 887 | timeoutAutoTimer.fn(); | ||
| 888 | check('auto-scroll retries once after bounded response timeout', timeoutScroll.sent.length, 2); | ||
| 889 | timeoutScroll.tile.canvas.dispatchEvent('pointercancel', { pointerId: 25 }); | ||
| 890 | |||
| 891 | const staleTimeout = makeTile(); | ||
| 892 | staleTimeout.tile.viewStartRow = 30; | ||
| 893 | staleTimeout.tile.requestedViewStartRow = 30; | ||
| 894 | staleTimeout.tile.paintScroll = () => {}; | ||
| 895 | staleTimeout.tile.changeScrollPages(1); | ||
| 896 | const firstTimeout = h.timers[staleTimeout.tile.scrollRequestTimer - 1]; | ||
| 897 | staleTimeout.tile.onMessage(scrollEnvelope(25)); | ||
| 898 | check('successful scroll reply clears its response timeout', firstTimeout?.cleared, true); | ||
| 899 | staleTimeout.tile.changeScrollPages(1); | ||
| 900 | const secondRequest = staleTimeout.tile.scrollRequest; | ||
| 901 | firstTimeout.fn(); | ||
| 902 | check('stale response timeout cannot roll back a later request', staleTimeout.tile.scrollRequest, secondRequest); | ||
| 903 | check('stale response timeout cannot change later requested page', staleTimeout.tile.scrollPages, 2); | ||
| 904 | |||
| 905 | const lifecycleScroll = makeTile(); | ||
| 906 | lifecycleScroll.tile.viewStartRow = 30; | ||
| 907 | lifecycleScroll.tile.requestedViewStartRow = 30; | ||
| 908 | lifecycleScroll.tile.connect(); | ||
| 909 | lifecycleScroll.tile.changeScrollPages(1); | ||
| 910 | const closeTimeout = h.timers[lifecycleScroll.tile.scrollRequestTimer - 1]; | ||
| 911 | lifecycleScroll.tile.ws.onclose(); | ||
| 912 | check('socket close rolls pending scroll request back', lifecycleScroll.tile.scrollRequest, null); | ||
| 913 | check('socket close clears pending scroll timeout', closeTimeout?.cleared, true); | ||
| 914 | lifecycleScroll.tile.changeScrollPages(1); | ||
| 915 | const openTimeout = h.timers[lifecycleScroll.tile.scrollRequestTimer - 1]; | ||
| 916 | lifecycleScroll.tile.ws.onopen(); | ||
| 917 | check('socket open invalidates pre-open scroll request', lifecycleScroll.tile.scrollRequest, null); | ||
| 918 | check('socket open clears pre-open scroll timeout', openTimeout?.cleared, true); | ||
| 919 | |||
| 920 | const attachScroll = makeTile(); | ||
| 921 | attachScroll.tile.viewStartRow = 30; | ||
| 922 | attachScroll.tile.requestedViewStartRow = 30; | ||
| 923 | attachScroll.tile.changeScrollPages(1); | ||
| 924 | const attachTimeout = h.timers[attachScroll.tile.scrollRequestTimer - 1]; | ||
| 925 | attachScroll.tile.sendAttach(false); | ||
| 926 | check('reattach rolls pending scroll request back', attachScroll.tile.scrollRequest, null); | ||
| 927 | check('reattach clears pending scroll timeout', attachTimeout?.cleared, true); | ||
| 928 | |||
| 929 | const controlLifecycle = makeTile(); | ||
| 930 | controlLifecycle.tile.viewStartRow = 30; | ||
| 931 | controlLifecycle.tile.requestedViewStartRow = 30; | ||
| 932 | controlLifecycle.tile.changeScrollPages(1); | ||
| 933 | const controlTimeout = h.timers[controlLifecycle.tile.scrollRequestTimer - 1]; | ||
| 934 | const reconnectingControl = Uint8Array.from([ | ||
| 935 | 1, ...Buffer.from(JSON.stringify({ state: 'reconnecting' }), 'utf8'), | ||
| 936 | ]); | ||
| 937 | controlLifecycle.tile.onMessage(reconnectingControl); | ||
| 938 | check('daemon reconnect lifecycle rolls pending scroll request back', controlLifecycle.tile.scrollRequest, null); | ||
| 939 | check('daemon reconnect lifecycle clears pending scroll timeout', controlTimeout?.cleared, true); | ||
| 940 | |||
| 941 | const exitLifecycle = makeTile(); | ||
| 942 | exitLifecycle.tile.viewStartRow = 30; | ||
| 943 | exitLifecycle.tile.requestedViewStartRow = 30; | ||
| 944 | exitLifecycle.tile.changeScrollPages(1); | ||
| 945 | const exitTimeout = h.timers[exitLifecycle.tile.scrollRequestTimer - 1]; | ||
| 946 | exitLifecycle.tile.onMessage(Uint8Array.from([0, 0x82, 1, 0, 0, 0, 0])); | ||
| 947 | check('session exit rolls pending scroll request back', exitLifecycle.tile.scrollRequest, null); | ||
| 948 | check('session exit clears pending scroll timeout', exitTimeout?.cleared, true); | ||
| 949 | |||
| 950 | const countMismatch = makeTile(); | ||
| 951 | countMismatch.tile.viewStartRow = 30; | ||
| 952 | countMismatch.tile.requestedViewStartRow = 30; | ||
| 953 | let countPaints = 0; | ||
| 954 | countMismatch.tile.paintScroll = () => { countPaints++; }; | ||
| 955 | countMismatch.tile.changeScrollPages(1); | ||
| 956 | countMismatch.tile.onMessage(scrollEnvelope(25, 4)); | ||
| 957 | check('matching start with wrong echoed count is not painted', countPaints, 0); | ||
| 958 | check('wrong echoed count leaves exact request pending', countMismatch.tile.scrollRequest !== null, true); | ||
| 959 | countMismatch.tile.onMessage(scrollEnvelope(25, 5)); | ||
| 960 | check('matching start and count paints requested viewport', countPaints, 1); | ||
| 961 | |||
| 962 | const resizeMismatch = makeTile(); | ||
| 963 | resizeMismatch.tile.viewStartRow = 30; | ||
| 964 | resizeMismatch.tile.requestedViewStartRow = 30; | ||
| 965 | resizeMismatch.tile.changeScrollPages(1); | ||
| 966 | const resizeTimeout = h.timers[resizeMismatch.tile.scrollRequestTimer - 1]; | ||
| 967 | resizeMismatch.tile.zoomCols = () => 11; | ||
| 968 | resizeMismatch.tile.zoomRows = () => 6; | ||
| 969 | resizeMismatch.tile.sendResizeIfDiffers(); | ||
| 970 | check('grid resize invalidates old-count scroll request', resizeMismatch.tile.scrollRequest, null); | ||
| 971 | check('grid resize clears old-count scroll timeout', resizeTimeout?.cleared, true); | ||
| 972 | |||
| 845 | const coordinate = makeTile(); | 973 | const coordinate = makeTile(); |
| 846 | coordinate.tile.viewStartRow = 40; | 974 | coordinate.tile.viewStartRow = 40; |
| 847 | check( | 975 | check( |
| @@ -1046,6 +1174,7 @@ async function verifySelectionShell(shell, html) { | |||
| 1046 | auto.tile.onMessage(scrollEnvelope(25)); | 1174 | auto.tile.onMessage(scrollEnvelope(25)); |
| 1047 | check('matching successful history reply advances painted viewport', auto.tile.viewStartRow, 25); | 1175 | 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); | 1176 | check('matching successful history reply moves drag to painted boundary', auto.tile.selection.active.row, 25); |
| 1177 | check('matching successful history reply remaps latest pointer column', auto.tile.selection.active.col, 4); | ||
| 1049 | check('matching successful history reply retains boundary view row', auto.tile.selection.active.viewRow, 0); | 1178 | 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); | 1179 | check('matching successful history reply paints once', autoPaints, 1); |
| 1051 | autoTimer.fn(); | 1180 | autoTimer.fn(); |
| @@ -1057,6 +1186,39 @@ async function verifySelectionShell(shell, html) { | |||
| 1057 | ); | 1186 | ); |
| 1058 | auto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 18 }); | 1187 | auto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 18 }); |
| 1059 | 1188 | ||
| 1189 | const insideDrift = makeTile(); | ||
| 1190 | insideDrift.tile.viewStartRow = 30; | ||
| 1191 | insideDrift.tile.requestedViewStartRow = 30; | ||
| 1192 | insideDrift.tile.paintScroll = () => {}; | ||
| 1193 | insideDrift.tile.canvas.dispatchEvent('pointerdown', pointer(26, 2, 2)); | ||
| 1194 | insideDrift.tile.canvas.dispatchEvent('pointermove', { | ||
| 1195 | ...pointer(26, 4, 0), clientY: 10, | ||
| 1196 | }); | ||
| 1197 | h.intervals[insideDrift.tile.selectionScrollTimer - 1].fn(); | ||
| 1198 | insideDrift.tile.canvas.dispatchEvent('pointermove', pointer(26, 7, 3)); | ||
| 1199 | check('pointer returning inside pending page still names old painted row', insideDrift.tile.selection.active.row, 33); | ||
| 1200 | insideDrift.tile.onMessage(scrollEnvelope(25)); | ||
| 1201 | check('matching paint remaps inside pointer to new painted row', insideDrift.tile.selection.active.row, 28); | ||
| 1202 | check('matching paint remaps latest inside pointer X', insideDrift.tile.selection.active.col, 7); | ||
| 1203 | insideDrift.tile.canvas.dispatchEvent('pointercancel', { pointerId: 26 }); | ||
| 1204 | |||
| 1205 | const outsideXDrift = makeTile(); | ||
| 1206 | outsideXDrift.tile.viewStartRow = 30; | ||
| 1207 | outsideXDrift.tile.requestedViewStartRow = 30; | ||
| 1208 | outsideXDrift.tile.paintScroll = () => {}; | ||
| 1209 | outsideXDrift.tile.canvas.dispatchEvent('pointerdown', pointer(27, 2, 2)); | ||
| 1210 | outsideXDrift.tile.canvas.dispatchEvent('pointermove', { | ||
| 1211 | ...pointer(27, 4, 0), clientY: 10, | ||
| 1212 | }); | ||
| 1213 | h.intervals[outsideXDrift.tile.selectionScrollTimer - 1].fn(); | ||
| 1214 | outsideXDrift.tile.canvas.dispatchEvent('pointermove', { | ||
| 1215 | ...pointer(27, 8, 0), clientY: 10, | ||
| 1216 | }); | ||
| 1217 | outsideXDrift.tile.onMessage(scrollEnvelope(25)); | ||
| 1218 | check('matching paint remaps changed outside pointer X', outsideXDrift.tile.selection.active.col, 8); | ||
| 1219 | check('matching paint clamps changed outside pointer to painted boundary', outsideXDrift.tile.selection.active.row, 25); | ||
| 1220 | outsideXDrift.tile.canvas.dispatchEvent('pointercancel', { pointerId: 27 }); | ||
| 1221 | |||
| 1060 | const failedAuto = makeTile(); | 1222 | const failedAuto = makeTile(); |
| 1061 | failedAuto.tile.viewStartRow = 30; | 1223 | failedAuto.tile.viewStartRow = 30; |
| 1062 | failedAuto.tile.requestedViewStartRow = 30; | 1224 | failedAuto.tile.requestedViewStartRow = 30; |
| @@ -1067,10 +1229,12 @@ async function verifySelectionShell(shell, html) { | |||
| 1067 | const failedAutoTimer = h.intervals[failedAuto.tile.selectionScrollTimer - 1]; | 1229 | const failedAutoTimer = h.intervals[failedAuto.tile.selectionScrollTimer - 1]; |
| 1068 | failedAutoTimer.fn(); | 1230 | failedAutoTimer.fn(); |
| 1069 | failedAuto.setScrollFeedResult(-3); | 1231 | failedAuto.setScrollFeedResult(-3); |
| 1232 | const failedDecodeTimeout = h.timers[failedAuto.tile.scrollRequestTimer - 1]; | ||
| 1070 | failedAuto.tile.onMessage(scrollEnvelope(25)); | 1233 | failedAuto.tile.onMessage(scrollEnvelope(25)); |
| 1071 | check('failed auto-scroll reply preserves painted viewport', failedAuto.tile.viewStartRow, 30); | 1234 | 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); | 1235 | 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); | 1236 | check('failed auto-scroll reply rolls requested page back', failedAuto.tile.scrollPages, 0); |
| 1237 | check('failed auto-scroll reply clears response timeout', failedDecodeTimeout?.cleared, true); | ||
| 1074 | failedAuto.setScrollFeedResult(0); | 1238 | failedAuto.setScrollFeedResult(0); |
| 1075 | failedAutoTimer.fn(); | 1239 | failedAutoTimer.fn(); |
| 1076 | check('auto-scroll retries the same page after a failed decode', failedAuto.sent.length, 2); | 1240 | check('auto-scroll retries the same page after a failed decode', failedAuto.sent.length, 2); |
| @@ -1091,9 +1255,11 @@ async function verifySelectionShell(shell, html) { | |||
| 1091 | const failedStageTimer = h.intervals[failedAutoStage.tile.selectionScrollTimer - 1]; | 1255 | const failedStageTimer = h.intervals[failedAutoStage.tile.selectionScrollTimer - 1]; |
| 1092 | failedStageTimer.fn(); | 1256 | failedStageTimer.fn(); |
| 1093 | failedAutoStage.tile.stage = () => false; | 1257 | failedAutoStage.tile.stage = () => false; |
| 1258 | const failedStageTimeout = h.timers[failedAutoStage.tile.scrollRequestTimer - 1]; | ||
| 1094 | failedAutoStage.tile.onMessage(scrollEnvelope(25)); | 1259 | failedAutoStage.tile.onMessage(scrollEnvelope(25)); |
| 1095 | check('failed auto-scroll staging rolls requested page back', failedAutoStage.tile.scrollPages, 0); | 1260 | 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); | 1261 | check('failed auto-scroll staging preserves painted endpoint', failedAutoStage.tile.selection.active.row, 30); |
| 1262 | check('failed auto-scroll staging clears response timeout', failedStageTimeout?.cleared, true); | ||
| 1097 | failedAutoStage.tile.stage = h.Tile.prototype.stage.bind(failedAutoStage.tile); | 1263 | failedAutoStage.tile.stage = h.Tile.prototype.stage.bind(failedAutoStage.tile); |
| 1098 | failedStageTimer.fn(); | 1264 | failedStageTimer.fn(); |
| 1099 | check('auto-scroll retries after failed staging', failedAutoStage.sent.length, 2); | 1265 | check('auto-scroll retries after failed staging', failedAutoStage.sent.length, 2); |
| @@ -1147,6 +1313,38 @@ async function verifySelectionShell(shell, html) { | |||
| 1147 | check('late scroll reply after release cannot change finalized selection', JSON.stringify(releaseRace.tile.selection.active), finalizedActive); | 1313 | 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); | 1314 | check('late scroll reply after release sends no second selection request', releaseRace.requestCalls.length, 1); |
| 1149 | 1315 | ||
| 1316 | const finalUp = makeTile(); | ||
| 1317 | finalUp.tile.viewStartRow = 30; | ||
| 1318 | finalUp.tile.requestedViewStartRow = 30; | ||
| 1319 | finalUp.tile.canvas.dispatchEvent('pointerdown', pointer(28, 2, 1)); | ||
| 1320 | finalUp.tile.canvas.dispatchEvent('pointerup', pointer(28, 6, 4)); | ||
| 1321 | check( | ||
| 1322 | 'pointerup samples final coordinates even without a last pointermove', | ||
| 1323 | JSON.stringify(finalUp.requestCalls), | ||
| 1324 | JSON.stringify([[1, 31, 2, 34, 6]]), | ||
| 1325 | ); | ||
| 1326 | check('pointerup-only endpoint movement repaints retained overlay once', finalUp.reflows(), 2); | ||
| 1327 | |||
| 1328 | const finalPendingUp = makeTile(); | ||
| 1329 | finalPendingUp.tile.viewStartRow = 30; | ||
| 1330 | finalPendingUp.tile.requestedViewStartRow = 30; | ||
| 1331 | finalPendingUp.tile.paintScroll = () => {}; | ||
| 1332 | finalPendingUp.tile.canvas.dispatchEvent('pointerdown', pointer(29, 2, 2)); | ||
| 1333 | finalPendingUp.tile.canvas.dispatchEvent('pointermove', { | ||
| 1334 | ...pointer(29, 4, 0), clientY: 10, | ||
| 1335 | }); | ||
| 1336 | h.intervals[finalPendingUp.tile.selectionScrollTimer - 1].fn(); | ||
| 1337 | finalPendingUp.tile.canvas.dispatchEvent('pointerup', pointer(29, 7, 3)); | ||
| 1338 | check( | ||
| 1339 | 'pointerup before page reply samples final point on displayed viewport', | ||
| 1340 | JSON.stringify(finalPendingUp.requestCalls), | ||
| 1341 | JSON.stringify([[1, 32, 2, 33, 7]]), | ||
| 1342 | ); | ||
| 1343 | check('changed pointerup after move adds one final overlay repaint', finalPendingUp.reflows(), 3); | ||
| 1344 | const finalPendingPoint = JSON.stringify(finalPendingUp.tile.selection.active); | ||
| 1345 | finalPendingUp.tile.onMessage(scrollEnvelope(25)); | ||
| 1346 | check('late painted page cannot remap final pointerup point', JSON.stringify(finalPendingUp.tile.selection.active), finalPendingPoint); | ||
| 1347 | |||
| 1150 | const painted = makeTile(); | 1348 | const painted = makeTile(); |
| 1151 | const order = []; | 1349 | const order = []; |
| 1152 | painted.tile.sizeCanvas = () => {}; | 1350 | painted.tile.sizeCanvas = () => {}; |
| @@ -1432,6 +1630,31 @@ async function verifySelectionShell(shell, html) { | |||
| 1432 | check('composition handling precedes selection copy chords', composingWrites, 0); | 1630 | check('composition handling precedes selection copy chords', composingWrites, 0); |
| 1433 | check('composing copy chord remains unprevented', composingEvent.wasPrevented(), false); | 1631 | check('composing copy chord remains unprevented', composingEvent.wasPrevented(), false); |
| 1434 | 1632 | ||
| 1633 | const selectionRetryTab = makeTile(); | ||
| 1634 | selectionRetryTab.tile.selection = authoritativeSelection('retry by keyboard'); | ||
| 1635 | selectionRetryTab.tile.renderCopyUi('selection', 'copy-request on error', 'Copy failed'); | ||
| 1636 | h.setZoomedTile(selectionRetryTab.tile); | ||
| 1637 | h.elements.ime.focus(); | ||
| 1638 | const selectionTab = keyEvent({ key: 'Tab' }); | ||
| 1639 | h.document.dispatchEvent('keydown', selectionTab); | ||
| 1640 | check('plain Tab reaches actionable selection-copy failure', selectionTab.wasPrevented(), true); | ||
| 1641 | check('selection-copy retry receives keyboard focus', h.document.activeElement, selectionRetryTab.tile.copyButton); | ||
| 1642 | check('selection-copy retry Tab sends no PTY bytes', selectionRetryTab.sent.length, 0); | ||
| 1643 | |||
| 1644 | const unavailableTab = makeTile(); | ||
| 1645 | unavailableTab.tile.selection = authoritativeSelection(null); | ||
| 1646 | unavailableTab.tile.pendingClipboard = 'unrelated hidden OSC52'; | ||
| 1647 | unavailableTab.tile.renderCopyUi('selection', 'copy-request on error', 'Selection unavailable'); | ||
| 1648 | unavailableTab.tile.core.mux_key_encode = () => 1; | ||
| 1649 | unavailableTab.tile.core.mux_output_len = () => 1; | ||
| 1650 | h.setZoomedTile(unavailableTab.tile); | ||
| 1651 | h.elements.ime.focus(); | ||
| 1652 | const unavailableSelectionTab = keyEvent({ key: 'Tab' }); | ||
| 1653 | h.document.dispatchEvent('keydown', unavailableSelectionTab); | ||
| 1654 | check('unavailable selection does not advertise an actionable retry', h.document.activeElement, h.elements.ime); | ||
| 1655 | check('unavailable selection Tab keeps terminal key handling', unavailableSelectionTab.wasPrevented(), true); | ||
| 1656 | check('unavailable selection Tab reaches PTY once', unavailableTab.sent.length, 1); | ||
| 1657 | |||
| 1435 | const scrollExitKey = makeTile(); | 1658 | const scrollExitKey = makeTile(); |
| 1436 | scrollExitKey.tile.scrollPages = 1; | 1659 | scrollExitKey.tile.scrollPages = 1; |
| 1437 | scrollExitKey.tile.viewStartRow = 25; | 1660 | scrollExitKey.tile.viewStartRow = 25; |
| @@ -1517,7 +1740,7 @@ async function verifySelectionShell(shell, html) { | |||
| 1517 | check('explicit selection success reports Copied', `${clipboardRace.tile.copyButton.className}|${clipboardRace.tile.copyButton.textContent}`, 'copy-request on|Copied'); | 1740 | check('explicit selection success reports Copied', `${clipboardRace.tile.copyButton.className}|${clipboardRace.tile.copyButton.textContent}`, 'copy-request on|Copied'); |
| 1518 | oldOsc.resolve(); | 1741 | oldOsc.resolve(); |
| 1519 | await oldOscRun; | 1742 | await oldOscRun; |
| 1520 | check('older OSC52 settlement cannot overwrite selection-copy success', `${clipboardRace.tile.copyButton.className}|${clipboardRace.tile.copyButton.textContent}`, 'copy-request on|Copied'); | 1743 | check('older OSC52 settlement cannot overwrite selection-copy success UI', `${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); | 1744 | check('older OSC52 still completes its serialized pending state', clipboardRace.tile.pendingClipboard, null); |
| 1522 | 1745 | ||
| 1523 | const oldTimerRace = makeTile(); | 1746 | const oldTimerRace = makeTile(); |
| @@ -1550,6 +1773,7 @@ async function verifySelectionShell(shell, html) { | |||
| 1550 | copyFailure.tile.copyButton.dispatchEvent('click', { stopPropagation() {} }); | 1773 | copyFailure.tile.copyButton.dispatchEvent('click', { stopPropagation() {} }); |
| 1551 | await flushPromises(); | 1774 | await flushPromises(); |
| 1552 | check('selection failure button retries the retained selection only', retryWrites.join('|'), 'retry exact selection'); | 1775 | check('selection failure button retries the retained selection only', retryWrites.join('|'), 'retry exact selection'); |
| 1776 | check('selection retry click restores hidden IME focus', h.document.activeElement, h.elements.ime); | ||
| 1553 | 1777 | ||
| 1554 | const invalidatedCopy = makeTile(); | 1778 | const invalidatedCopy = makeTile(); |
| 1555 | invalidatedCopy.tile.selection = authoritativeSelection('old selection'); | 1779 | invalidatedCopy.tile.selection = authoritativeSelection('old selection'); |
| @@ -1588,6 +1812,16 @@ async function verifySelectionShell(shell, html) { | |||
| 1588 | await unzoomCopyRun; | 1812 | await unzoomCopyRun; |
| 1589 | check('late selection-copy failure after unzoom remains invisible', `${unzoomCopy.tile.copyButton.className}|${unzoomCopy.tile.copyButton.textContent}`, 'copy-request|Copy'); | 1813 | check('late selection-copy failure after unzoom remains invisible', `${unzoomCopy.tile.copyButton.className}|${unzoomCopy.tile.copyButton.textContent}`, 'copy-request|Copy'); |
| 1590 | 1814 | ||
| 1815 | const unzoomScroll = makeTile(); | ||
| 1816 | unzoomScroll.tile.viewStartRow = 30; | ||
| 1817 | unzoomScroll.tile.requestedViewStartRow = 30; | ||
| 1818 | unzoomScroll.tile.changeScrollPages(1); | ||
| 1819 | const unzoomScrollTimeout = h.timers[unzoomScroll.tile.scrollRequestTimer - 1]; | ||
| 1820 | h.setZoomedTile(unzoomScroll.tile); | ||
| 1821 | h.unzoom(); | ||
| 1822 | check('unzoom invalidates pending scroll request', unzoomScroll.tile.scrollRequest, null); | ||
| 1823 | check('unzoom clears pending scroll timeout', unzoomScrollTimeout?.cleared, true); | ||
| 1824 | |||
| 1591 | const newSelectionUi = makeTile(); | 1825 | const newSelectionUi = makeTile(); |
| 1592 | newSelectionUi.tile.selection = { | 1826 | newSelectionUi.tile.selection = { |
| 1593 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 76, text: null, | 1827 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 76, text: null, |
| @@ -1614,17 +1848,23 @@ async function verifySelectionShell(shell, html) { | |||
| 1614 | anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 7, text: 'resolved old session', | 1848 | anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 7, text: 'resolved old session', |
| 1615 | }; | 1849 | }; |
| 1616 | reset.tile.drag = { pointerId: 15, moved: true }; | 1850 | reset.tile.drag = { pointerId: 15, moved: true }; |
| 1617 | reset.tile.scrollPages = 1; | 1851 | reset.tile.lastPointerX = 20; |
| 1618 | reset.tile.scrollRequest = { fromPages: 0, start: 25 }; | 1852 | reset.tile.lastPointerY = 30; |
| 1853 | reset.tile.viewStartRow = 30; | ||
| 1854 | reset.tile.requestedViewStartRow = 30; | ||
| 1855 | reset.tile.changeScrollPages(1); | ||
| 1856 | const resetScrollTimeout = h.timers[reset.tile.scrollRequestTimer - 1]; | ||
| 1619 | reset.tile.canvas.setPointerCapture(15); | 1857 | reset.tile.canvas.setPointerCapture(15); |
| 1620 | reset.tile.selectionScrollTimer = h.context.setInterval(() => {}, 120); | 1858 | reset.tile.selectionScrollTimer = h.context.setInterval(() => {}, 120); |
| 1621 | const resetTimer = reset.tile.selectionScrollTimer; | 1859 | const resetTimer = reset.tile.selectionScrollTimer; |
| 1622 | reset.tile.resetCore('selection test'); | 1860 | reset.tile.resetCore('selection test'); |
| 1623 | check('destructive core reset clears resolved selection', reset.tile.selection, null); | 1861 | check('destructive core reset clears resolved selection', reset.tile.selection, null); |
| 1624 | check('destructive core reset clears drag state', reset.tile.drag, null); | 1862 | check('destructive core reset clears drag state', reset.tile.drag, null); |
| 1625 | check('destructive core reset clears pointer position', reset.tile.lastPointerY, null); | 1863 | check('destructive core reset clears pointer X', reset.tile.lastPointerX, null); |
| 1864 | check('destructive core reset clears pointer Y', reset.tile.lastPointerY, null); | ||
| 1626 | check('destructive core reset stops selection timer', h.intervals[resetTimer - 1]?.cleared, true); | 1865 | 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); | 1866 | check('destructive core reset clears orphaned scroll request', reset.tile.scrollRequest, null); |
| 1867 | check('destructive core reset clears scroll timeout', resetScrollTimeout?.cleared, true); | ||
| 1628 | 1868 | ||
| 1629 | const reconnect = makeTile(); | 1869 | const reconnect = makeTile(); |
| 1630 | reconnect.tile.canvas.dispatchEvent('pointerdown', pointer(16, 1, 1)); | 1870 | reconnect.tile.canvas.dispatchEvent('pointerdown', pointer(16, 1, 1)); |
| @@ -1634,7 +1874,8 @@ async function verifySelectionShell(shell, html) { | |||
| 1634 | reconnect.tile.ws.onopen(); | 1874 | reconnect.tile.ws.onopen(); |
| 1635 | check('new socket clears pending selection from prior connection', reconnect.tile.selection, null); | 1875 | check('new socket clears pending selection from prior connection', reconnect.tile.selection, null); |
| 1636 | check('new socket clears prior drag state', reconnect.tile.drag, null); | 1876 | check('new socket clears prior drag state', reconnect.tile.drag, null); |
| 1637 | check('new socket clears prior pointer position', reconnect.tile.lastPointerY, null); | 1877 | check('new socket clears prior pointer X', reconnect.tile.lastPointerX, null); |
| 1878 | check('new socket clears prior pointer Y', reconnect.tile.lastPointerY, null); | ||
| 1638 | check('new socket stops prior selection timer', h.intervals[reconnectTimer - 1]?.cleared, true); | 1879 | check('new socket stops prior selection timer', h.intervals[reconnectTimer - 1]?.cleared, true); |
| 1639 | 1880 | ||
| 1640 | const reattach = makeTile(); | 1881 | const reattach = makeTile(); |