c1c27e1f
fix: harden retained web selection state
a73x 2026-08-18 12:27
Commit message
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -131,7 +131,9 @@ class Tile { | |||
| 131 | this.pendingClipboard = null; | 131 | this.pendingClipboard = null; |
| 132 | this.clipboardVersion = 0; | 132 | this.clipboardVersion = 0; |
| 133 | this.clipboardWriteActive = false; | 133 | this.clipboardWriteActive = false; |
| 134 | this.copyUiOwner = null; // null, 'clipboard', or 'selection' | ||
| 134 | this.viewStartRow = 0; | 135 | this.viewStartRow = 0; |
| 136 | this.requestedViewStartRow = 0; | ||
| 135 | this.selection = null; // {anchor, active, requestId, text} | 137 | this.selection = null; // {anchor, active, requestId, text} |
| 136 | this.nextSelectionId = 0; | 138 | this.nextSelectionId = 0; |
| 137 | this.drag = null; | 139 | this.drag = null; |
| @@ -146,7 +148,7 @@ class Tile { | |||
| 146 | this.copyButton = this.el.querySelector('.copy-request'); | 148 | this.copyButton = this.el.querySelector('.copy-request'); |
| 147 | this.copyButton.addEventListener('click', (ev) => { | 149 | this.copyButton.addEventListener('click', (ev) => { |
| 148 | ev.stopPropagation(); | 150 | ev.stopPropagation(); |
| 149 | this.copyPendingClipboard(); | 151 | if (this.copyUiOwner === 'clipboard') this.copyPendingClipboard(); |
| 150 | ime.focus(); | 152 | ime.focus(); |
| 151 | }); | 153 | }); |
| 152 | this.canvas = document.createElement('canvas'); | 154 | this.canvas = document.createElement('canvas'); |
| @@ -167,6 +169,9 @@ class Tile { | |||
| 167 | this.canvas.addEventListener('pointercancel', (ev) => { | 169 | this.canvas.addEventListener('pointercancel', (ev) => { |
| 168 | if (this.drag && ev.pointerId === this.drag.pointerId) this.clearSelection(); | 170 | if (this.drag && ev.pointerId === this.drag.pointerId) this.clearSelection(); |
| 169 | }); | 171 | }); |
| 172 | this.canvas.addEventListener('lostpointercapture', (ev) => { | ||
| 173 | if (this.drag && ev.pointerId === this.drag.pointerId) this.clearSelection(); | ||
| 174 | }); | ||
| 170 | } | 175 | } |
| 171 | 176 | ||
| 172 | async start() { | 177 | async start() { |
| @@ -195,6 +200,7 @@ class Tile { | |||
| 195 | this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); | 200 | this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); |
| 196 | this.ws.binaryType = 'arraybuffer'; | 201 | this.ws.binaryType = 'arraybuffer'; |
| 197 | this.ws.onopen = () => { | 202 | this.ws.onopen = () => { |
| 203 | this.clearSelection(false); | ||
| 198 | this.wsOpened = true; | 204 | this.wsOpened = true; |
| 199 | this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule | 205 | this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule |
| 200 | this.wsFailures = 0; | 206 | this.wsFailures = 0; |
| @@ -253,6 +259,7 @@ class Tile { | |||
| 253 | // it held is gone, so the attach quotes (0,0) and the daemon answers | 259 | // it held is gone, so the attach quotes (0,0) and the daemon answers |
| 254 | // with a snapshot. | 260 | // with a snapshot. |
| 255 | resetCore(why) { | 261 | resetCore(why) { |
| 262 | this.clearSelection(false); | ||
| 256 | if (this.core.mux_init(80, 24) !== 0) { | 263 | if (this.core.mux_init(80, 24) !== 0) { |
| 257 | this.replayDead = true; | 264 | this.replayDead = true; |
| 258 | this.setStatus('stuck', 'core failed'); | 265 | this.setStatus('stuck', 'core failed'); |
| @@ -297,6 +304,7 @@ class Tile { | |||
| 297 | // control message included. A tile that gave up on replaying must | 304 | // control message included. A tile that gave up on replaying must |
| 298 | // not be talked back into asking for the same frame again. | 305 | // not be talked back into asking for the same frame again. |
| 299 | if (this.replayDead) return; | 306 | if (this.replayDead) return; |
| 307 | this.clearSelection(false); | ||
| 300 | const cols = this.zoomed ? this.zoomCols() : 1; | 308 | const cols = this.zoomed ? this.zoomCols() : 1; |
| 301 | const rows = this.zoomed ? this.zoomRows() : 1; | 309 | const rows = this.zoomed ? this.zoomRows() : 1; |
| 302 | const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0); | 310 | const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0); |
| @@ -448,12 +456,15 @@ class Tile { | |||
| 448 | } | 456 | } |
| 449 | case MSG.scrollback_chunk: { | 457 | case MSG.scrollback_chunk: { |
| 450 | if (this.scrollPages === 0 || payload.length < 6) return; | 458 | if (this.scrollPages === 0 || payload.length < 6) return; |
| 451 | this.viewStartRow = new DataView( | 459 | const start = new DataView( |
| 452 | payload.buffer, payload.byteOffset, 6, | 460 | payload.buffer, payload.byteOffset, 6, |
| 453 | ).getUint32(0, true); | 461 | ).getUint32(0, true); |
| 454 | const rows = payload.subarray(6); // echoed start+count stripped | 462 | const rows = payload.subarray(6); // echoed start+count stripped |
| 455 | if (!this.stage(rows)) return; | 463 | if (!this.stage(rows)) return; |
| 456 | if (this.core.mux_scroll_feed(rows.length) === 0) this.paintScroll(); | 464 | if (this.core.mux_scroll_feed(rows.length) === 0) { |
| 465 | this.viewStartRow = start; | ||
| 466 | this.paintScroll(); | ||
| 467 | } | ||
| 457 | return; | 468 | return; |
| 458 | } | 469 | } |
| 459 | case MSG.term_modes: | 470 | case MSG.term_modes: |
| @@ -485,11 +496,29 @@ class Tile { | |||
| 485 | 496 | ||
| 486 | const version = ++this.clipboardVersion; | 497 | const version = ++this.clipboardVersion; |
| 487 | this.pendingClipboard = text; | 498 | this.pendingClipboard = text; |
| 488 | this.copyButton.className = 'copy-request'; | 499 | this.renderCopyUi('clipboard', 'copy-request', 'Copy'); |
| 489 | this.copyButton.textContent = 'Copy'; | ||
| 490 | return this.tryClipboardWrite(version, true); | 500 | return this.tryClipboardWrite(version, true); |
| 491 | } | 501 | } |
| 492 | 502 | ||
| 503 | renderCopyUi(owner, className, text) { | ||
| 504 | this.copyUiOwner = owner; | ||
| 505 | this.copyButton.className = className; | ||
| 506 | this.copyButton.textContent = text; | ||
| 507 | } | ||
| 508 | |||
| 509 | releaseSelectionCopyUi() { | ||
| 510 | if (this.copyUiOwner !== 'selection') return; | ||
| 511 | if (this.pendingClipboard !== null) { | ||
| 512 | this.renderCopyUi( | ||
| 513 | 'clipboard', | ||
| 514 | this.clipboardWriteActive ? 'copy-request' : 'copy-request on', | ||
| 515 | 'Copy', | ||
| 516 | ); | ||
| 517 | } else { | ||
| 518 | this.renderCopyUi(null, 'copy-request', 'Copy'); | ||
| 519 | } | ||
| 520 | } | ||
| 521 | |||
| 493 | async tryClipboardWrite(version, automatic) { | 522 | async tryClipboardWrite(version, automatic) { |
| 494 | if (this.clipboardWriteActive) return; | 523 | if (this.clipboardWriteActive) return; |
| 495 | if (version !== this.clipboardVersion || this.pendingClipboard === null) return; | 524 | if (version !== this.clipboardVersion || this.pendingClipboard === null) return; |
| @@ -516,25 +545,28 @@ class Tile { | |||
| 516 | } | 545 | } |
| 517 | 546 | ||
| 518 | if (!succeeded) { | 547 | if (!succeeded) { |
| 519 | this.copyButton.className = automatic | 548 | if (this.copyUiOwner === 'clipboard') { |
| 520 | ? 'copy-request on' | 549 | this.renderCopyUi( |
| 521 | : 'copy-request on error'; | 550 | 'clipboard', |
| 522 | this.copyButton.textContent = automatic ? 'Copy' : 'Copy failed'; | 551 | automatic ? 'copy-request on' : 'copy-request on error', |
| 552 | automatic ? 'Copy' : 'Copy failed', | ||
| 553 | ); | ||
| 554 | } | ||
| 523 | return; | 555 | return; |
| 524 | } | 556 | } |
| 525 | 557 | ||
| 526 | this.pendingClipboard = null; | 558 | this.pendingClipboard = null; |
| 527 | this.copyButton.className = 'copy-request on'; | 559 | if (this.copyUiOwner !== 'clipboard') return; |
| 528 | this.copyButton.textContent = 'Copied'; | 560 | this.renderCopyUi('clipboard', 'copy-request on', 'Copied'); |
| 529 | setTimeout(() => { | 561 | setTimeout(() => { |
| 530 | if (version !== this.clipboardVersion || this.pendingClipboard !== null) return; | 562 | if (version !== this.clipboardVersion || this.pendingClipboard !== null || |
| 531 | this.copyButton.className = 'copy-request'; | 563 | this.copyUiOwner !== 'clipboard') return; |
| 532 | this.copyButton.textContent = 'Copy'; | 564 | this.renderCopyUi(null, 'copy-request', 'Copy'); |
| 533 | }, 1200); | 565 | }, 1200); |
| 534 | } | 566 | } |
| 535 | 567 | ||
| 536 | copyPendingClipboard() { | 568 | copyPendingClipboard() { |
| 537 | if (!this.zoomed || this.pendingClipboard === null) return; | 569 | if (!this.zoomed || this.copyUiOwner !== 'clipboard' || this.pendingClipboard === null) return; |
| 538 | return this.tryClipboardWrite(this.clipboardVersion, false); | 570 | return this.tryClipboardWrite(this.clipboardVersion, false); |
| 539 | } | 571 | } |
| 540 | 572 | ||
| @@ -544,8 +576,7 @@ class Tile { | |||
| 544 | if ((this.core.mux_selection_id() >>> 0) !== this.selection.requestId) return; | 576 | if ((this.core.mux_selection_id() >>> 0) !== this.selection.requestId) return; |
| 545 | if (this.core.mux_selection_status() !== 0) { | 577 | if (this.core.mux_selection_status() !== 0) { |
| 546 | this.selection.text = null; | 578 | this.selection.text = null; |
| 547 | this.copyButton.className = 'copy-request on error'; | 579 | this.renderCopyUi('selection', 'copy-request on error', 'Selection unavailable'); |
| 548 | this.copyButton.textContent = 'Selection unavailable'; | ||
| 549 | return; | 580 | return; |
| 550 | } | 581 | } |
| 551 | const ptr = this.core.mux_selection_ptr(); | 582 | const ptr = this.core.mux_selection_ptr(); |
| @@ -555,8 +586,10 @@ class Tile { | |||
| 555 | const bytes = new Uint8Array(this.core.memory.buffer).slice(ptr, ptr + len); | 586 | const bytes = new Uint8Array(this.core.memory.buffer).slice(ptr, ptr + len); |
| 556 | try { | 587 | try { |
| 557 | this.selection.text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); | 588 | this.selection.text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); |
| 589 | this.releaseSelectionCopyUi(); | ||
| 558 | } catch (_) { | 590 | } catch (_) { |
| 559 | this.selection.text = null; | 591 | this.selection.text = null; |
| 592 | this.renderCopyUi('selection', 'copy-request on error', 'Selection unavailable'); | ||
| 560 | } | 593 | } |
| 561 | } | 594 | } |
| 562 | 595 | ||
| @@ -580,10 +613,21 @@ class Tile { | |||
| 580 | 613 | ||
| 581 | clearSelection(repaint = true) { | 614 | clearSelection(repaint = true) { |
| 582 | this.selection = null; | 615 | this.selection = null; |
| 616 | this.stopSelectionDrag(); | ||
| 617 | this.releaseSelectionCopyUi(); | ||
| 618 | if (repaint && this.core) this.reflow(); | ||
| 619 | } | ||
| 620 | |||
| 621 | stopSelectionDrag() { | ||
| 622 | const pointerId = this.drag?.pointerId; | ||
| 583 | this.drag = null; | 623 | this.drag = null; |
| 624 | this.lastPointerY = null; | ||
| 584 | if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer); | 625 | if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer); |
| 585 | this.selectionScrollTimer = null; | 626 | this.selectionScrollTimer = null; |
| 586 | if (repaint && this.core) this.reflow(); | 627 | // Clear drag state before release: releasePointerCapture queues a |
| 628 | // lostpointercapture event, which must not erase a completed selection. | ||
| 629 | if (pointerId !== undefined && this.canvas.hasPointerCapture(pointerId)) | ||
| 630 | this.canvas.releasePointerCapture(pointerId); | ||
| 587 | } | 631 | } |
| 588 | 632 | ||
| 589 | beginSelection(ev) { | 633 | beginSelection(ev) { |
| @@ -614,14 +658,10 @@ class Tile { | |||
| 614 | if (!this.drag || ev.pointerId !== this.drag.pointerId) return; | 658 | if (!this.drag || ev.pointerId !== this.drag.pointerId) return; |
| 615 | ev.preventDefault(); | 659 | ev.preventDefault(); |
| 616 | const moved = this.drag.moved; | 660 | const moved = this.drag.moved; |
| 617 | if (this.canvas.hasPointerCapture(ev.pointerId)) this.canvas.releasePointerCapture(ev.pointerId); | 661 | this.stopSelectionDrag(); |
| 618 | if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer); | ||
| 619 | this.selectionScrollTimer = null; | ||
| 620 | this.drag = null; | ||
| 621 | if (!moved) { this.clearSelection(); return; } | 662 | if (!moved) { this.clearSelection(); return; } |
| 622 | 663 | ||
| 623 | this.nextSelectionId = (this.nextSelectionId + 1) >>> 0; | 664 | this.nextSelectionId = (this.nextSelectionId + 1) >>> 0; |
| 624 | if (this.nextSelectionId === 0) this.nextSelectionId = 1; | ||
| 625 | this.selection.requestId = this.nextSelectionId; | 665 | this.selection.requestId = this.nextSelectionId; |
| 626 | const a = this.selection.anchor, b = this.selection.active; | 666 | const a = this.selection.anchor, b = this.selection.active; |
| 627 | const n = this.core.mux_selection_request( | 667 | const n = this.core.mux_selection_request( |
| @@ -683,9 +723,27 @@ class Tile { | |||
| 683 | } | 723 | } |
| 684 | paintLive() { | 724 | paintLive() { |
| 685 | this.viewStartRow = this.core.mux_history_rows(); | 725 | this.viewStartRow = this.core.mux_history_rows(); |
| 726 | this.requestedViewStartRow = this.viewStartRow; | ||
| 686 | this.sizeCanvas(); | 727 | this.sizeCanvas(); |
| 687 | const n = this.core.mux_read_viewport(); | 728 | const n = this.core.mux_read_viewport(); |
| 688 | for (let i = 0; i < n; i++) this.paintRow(this.core.mux_dirty_row(i)); | 729 | const paintedRows = new Set(); |
| 730 | for (let i = 0; i < n; i++) { | ||
| 731 | const row = this.core.mux_dirty_row(i); | ||
| 732 | this.paintRow(row); | ||
| 733 | paintedRows.add(row); | ||
| 734 | } | ||
| 735 | // The overlay is translucent. Restore every selected row from the | ||
| 736 | // terminal cells before compositing it again, even when a delta dirtied | ||
| 737 | // some other row, or repeated live paints would darken the selection. | ||
| 738 | const ordered = this.orderedSelection(); | ||
| 739 | if (ordered) { | ||
| 740 | const rows = this.core.mux_rows(); | ||
| 741 | const first = Math.max(0, ordered[0].row - this.viewStartRow); | ||
| 742 | const last = Math.min(rows - 1, ordered[1].row - this.viewStartRow); | ||
| 743 | for (let row = first; row <= last; row++) { | ||
| 744 | if (!paintedRows.has(row)) this.paintRow(row); | ||
| 745 | } | ||
| 746 | } | ||
| 689 | this.paintCursor(); | 747 | this.paintCursor(); |
| 690 | this.paintSelection(); | 748 | this.paintSelection(); |
| 691 | } | 749 | } |
| @@ -782,7 +840,7 @@ class Tile { | |||
| 782 | } | 840 | } |
| 783 | this.renderBadge(); | 841 | this.renderBadge(); |
| 784 | const start = this.core.mux_scroll_start(this.scrollPages, rows); | 842 | const start = this.core.mux_scroll_start(this.scrollPages, rows); |
| 785 | this.viewStartRow = start; | 843 | this.requestedViewStartRow = start; |
| 786 | const p = new Uint8Array(6); | 844 | const p = new Uint8Array(6); |
| 787 | const dv = new DataView(p.buffer); | 845 | const dv = new DataView(p.buffer); |
| 788 | dv.setUint32(0, start, true); | 846 | dv.setUint32(0, start, true); |
| @@ -880,8 +938,7 @@ function unzoom() { | |||
| 880 | // exit-scroll or wall reflow work can run. | 938 | // exit-scroll or wall reflow work can run. |
| 881 | was.clipboardVersion++; | 939 | was.clipboardVersion++; |
| 882 | was.pendingClipboard = null; | 940 | was.pendingClipboard = null; |
| 883 | was.copyButton.className = 'copy-request'; | 941 | was.renderCopyUi(null, 'copy-request', 'Copy'); |
| 884 | was.copyButton.textContent = 'Copy'; | ||
| 885 | was.exitScroll(); | 942 | was.exitScroll(); |
| 886 | was.zoomed = false; | 943 | was.zoomed = false; |
| 887 | was.el.classList.remove('zoomed'); | 944 | was.el.classList.remove('zoomed'); |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -383,7 +383,10 @@ function browserShell(source) { | |||
| 383 | getBoundingClientRect() { return this.rect; } | 383 | getBoundingClientRect() { return this.rect; } |
| 384 | setPointerCapture(pointerId) { this.capturedPointers.add(pointerId); } | 384 | setPointerCapture(pointerId) { this.capturedPointers.add(pointerId); } |
| 385 | hasPointerCapture(pointerId) { return this.capturedPointers.has(pointerId); } | 385 | hasPointerCapture(pointerId) { return this.capturedPointers.has(pointerId); } |
| 386 | releasePointerCapture(pointerId) { this.capturedPointers.delete(pointerId); } | 386 | releasePointerCapture(pointerId) { |
| 387 | this.capturedPointers.delete(pointerId); | ||
| 388 | this.dispatchEvent('lostpointercapture', { pointerId }); | ||
| 389 | } | ||
| 387 | getContext() { | 390 | getContext() { |
| 388 | return { | 391 | return { |
| 389 | measureText: () => ({ width: 8, fontBoundingBoxAscent: 11, fontBoundingBoxDescent: 3 }), | 392 | measureText: () => ({ width: 8, fontBoundingBoxAscent: 11, fontBoundingBoxDescent: 3 }), |
| @@ -629,6 +632,7 @@ async function verifyClipboardShell(shell, html) { | |||
| 629 | h.unzoom(); | 632 | h.unzoom(); |
| 630 | check('unzoom invalidates clipboard work before reflow', leaving.tile.clipboardVersion, 3); | 633 | check('unzoom invalidates clipboard work before reflow', leaving.tile.clipboardVersion, 3); |
| 631 | check('unzoom clears pending clipboard text', leaving.tile.pendingClipboard, null); | 634 | check('unzoom clears pending clipboard text', leaving.tile.pendingClipboard, null); |
| 635 | check('unzoom releases shared copy-control ownership', leaving.tile.copyUiOwner, null); | ||
| 632 | check('unzoom hides and resets copy button', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy'); | 636 | check('unzoom hides and resets copy button', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy'); |
| 633 | check('unzoom still reflows once', reflows, 1); | 637 | check('unzoom still reflows once', reflows, 1); |
| 634 | inFlight.resolve(); | 638 | inFlight.resolve(); |
| @@ -710,7 +714,7 @@ async function verifyClipboardShell(shell, html) { | |||
| 710 | check('page gives failed copy a distinct style', errorRule.test(executableHtmlCss), true); | 714 | check('page gives failed copy a distinct style', errorRule.test(executableHtmlCss), true); |
| 711 | } | 715 | } |
| 712 | 716 | ||
| 713 | function verifySelectionShell(shell, html) { | 717 | async function verifySelectionShell(shell, html) { |
| 714 | const h = browserShell(shell); | 718 | const h = browserShell(shell); |
| 715 | const wall = h.document.createElement('div'); | 719 | const wall = h.document.createElement('div'); |
| 716 | const tile = new h.Tile(3, 'selection', wall, ''); | 720 | const tile = new h.Tile(3, 'selection', wall, ''); |
| @@ -721,9 +725,11 @@ function verifySelectionShell(shell, html) { | |||
| 721 | && tile.lastPointerY === null | 725 | && tile.lastPointerY === null |
| 722 | && tile.selectionScrollTimer === null; | 726 | && tile.selectionScrollTimer === null; |
| 723 | check('tile initializes retained selection state', initialState, true); | 727 | check('tile initializes retained selection state', initialState, true); |
| 728 | check('tile initializes requested viewport state separately', tile.requestedViewStartRow, 0); | ||
| 729 | check('tile initializes shared copy-control ownership', tile.copyUiOwner, null); | ||
| 724 | check( | 730 | check( |
| 725 | 'tile binds all pointer selection events', | 731 | 'tile binds all pointer selection events', |
| 726 | ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'] | 732 | ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'lostpointercapture'] |
| 727 | .every((name) => tile.canvas.listeners.has(name)), | 733 | .every((name) => tile.canvas.listeners.has(name)), |
| 728 | true, | 734 | true, |
| 729 | ); | 735 | ); |
| @@ -751,6 +757,7 @@ function verifySelectionShell(shell, html) { | |||
| 751 | const selected = new h.Tile(4, 'selection fixture', h.document.createElement('div'), ''); | 757 | const selected = new h.Tile(4, 'selection fixture', h.document.createElement('div'), ''); |
| 752 | const memory = { buffer: new ArrayBuffer(1024) }; | 758 | const memory = { buffer: new ArrayBuffer(1024) }; |
| 753 | let requestResult = 16; | 759 | let requestResult = 16; |
| 760 | let scrollFeedResult = 0; | ||
| 754 | let result = { id: 0, status: 3, ptr: 96, len: 0 }; | 761 | let result = { id: 0, status: 3, ptr: 96, len: 0 }; |
| 755 | const requestCalls = []; | 762 | const requestCalls = []; |
| 756 | selected.core = { | 763 | selected.core = { |
| @@ -770,7 +777,9 @@ function verifySelectionShell(shell, html) { | |||
| 770 | mux_output_ptr: () => 256, | 777 | mux_output_ptr: () => 256, |
| 771 | mux_output_len: () => requestResult === 16 ? 16 : 0, | 778 | mux_output_len: () => requestResult === 16 ? 16 : 0, |
| 772 | mux_scroll_start: (pages, rows) => 30 - pages * rows, | 779 | mux_scroll_start: (pages, rows) => 30 - pages * rows, |
| 773 | mux_scroll_feed: () => 0, | 780 | mux_scroll_feed: () => scrollFeedResult, |
| 781 | mux_init: () => 0, | ||
| 782 | mux_attach_payload: () => 20, | ||
| 774 | mux_client_frame: (type) => type === 0x90 ? 4 : 0, | 783 | mux_client_frame: (type) => type === 0x90 ? 4 : 0, |
| 775 | mux_selection_id: () => result.id | 0, | 784 | mux_selection_id: () => result.id | 0, |
| 776 | mux_selection_status: () => result.status, | 785 | mux_selection_status: () => result.status, |
| @@ -798,6 +807,7 @@ function verifySelectionShell(shell, html) { | |||
| 798 | return { | 807 | return { |
| 799 | tile: selected, memory, requestCalls, sent, | 808 | tile: selected, memory, requestCalls, sent, |
| 800 | setRequestResult(value) { requestResult = value; }, | 809 | setRequestResult(value) { requestResult = value; }, |
| 810 | setScrollFeedResult(value) { scrollFeedResult = value; }, | ||
| 801 | setResult(id, status, textBytes) { | 811 | setResult(id, status, textBytes) { |
| 802 | const bytes = Uint8Array.from(textBytes); | 812 | const bytes = Uint8Array.from(textBytes); |
| 803 | new Uint8Array(memory.buffer).set(bytes, 96); | 813 | new Uint8Array(memory.buffer).set(bytes, 96); |
| @@ -857,6 +867,7 @@ function verifySelectionShell(shell, html) { | |||
| 857 | forward.tile.canvas.dispatchEvent('pointerup', up); | 867 | forward.tile.canvas.dispatchEvent('pointerup', up); |
| 858 | check('drag release prevents native pointer behavior', up.wasPrevented(), true); | 868 | check('drag release prevents native pointer behavior', up.wasPrevented(), true); |
| 859 | check('drag release gives up pointer capture', forward.tile.canvas.hasPointerCapture(7), false); | 869 | check('drag release gives up pointer capture', forward.tile.canvas.hasPointerCapture(7), false); |
| 870 | check('normal capture release preserves completed selection', forward.tile.selection !== null, true); | ||
| 860 | check('drag release clears the scroll timer', forward.tile.selectionScrollTimer, null); | 871 | check('drag release clears the scroll timer', forward.tile.selectionScrollTimer, null); |
| 861 | check('drag release sends exactly one frame', forward.sent.length, 1); | 872 | check('drag release sends exactly one frame', forward.sent.length, 1); |
| 862 | check('drag release uses selection-request wire type', forward.sent[0]?.type, 0x0b); | 873 | check('drag release uses selection-request wire type', forward.sent[0]?.type, 0x0b); |
| @@ -903,6 +914,17 @@ function verifySelectionShell(shell, html) { | |||
| 903 | check('pointer cancel stops the scroll timer', h.intervals[cancelTimer - 1]?.cleared, true); | 914 | check('pointer cancel stops the scroll timer', h.intervals[cancelTimer - 1]?.cleared, true); |
| 904 | check('pointer cancel sends no request', cancelled.sent.length, 0); | 915 | check('pointer cancel sends no request', cancelled.sent.length, 0); |
| 905 | 916 | ||
| 917 | const lost = makeTile(); | ||
| 918 | lost.tile.canvas.dispatchEvent('pointerdown', pointer(12, 2, 1)); | ||
| 919 | const lostTimer = lost.tile.selectionScrollTimer; | ||
| 920 | lost.tile.canvas.capturedPointers.delete(12); | ||
| 921 | lost.tile.canvas.dispatchEvent('lostpointercapture', { pointerId: 12 }); | ||
| 922 | check('unexpected lost pointer capture clears selection', lost.tile.selection, null); | ||
| 923 | check('unexpected lost pointer capture clears drag state', lost.tile.drag, null); | ||
| 924 | check('unexpected lost pointer capture clears pointer position', lost.tile.lastPointerY, null); | ||
| 925 | check('unexpected lost pointer capture stops the timer', h.intervals[lostTimer - 1]?.cleared, true); | ||
| 926 | check('unexpected lost pointer capture sends no request', lost.sent.length, 0); | ||
| 927 | |||
| 906 | const refused = makeTile(); | 928 | const refused = makeTile(); |
| 907 | refused.setRequestResult(-3); | 929 | refused.setRequestResult(-3); |
| 908 | refused.tile.canvas.dispatchEvent('pointerdown', pointer(11, 1, 1)); | 930 | refused.tile.canvas.dispatchEvent('pointerdown', pointer(11, 1, 1)); |
| @@ -913,9 +935,14 @@ function verifySelectionShell(shell, html) { | |||
| 913 | 935 | ||
| 914 | const scrolling = makeTile(); | 936 | const scrolling = makeTile(); |
| 915 | scrolling.tile.scrollPages = 0; | 937 | scrolling.tile.scrollPages = 0; |
| 938 | scrolling.tile.viewStartRow = 30; | ||
| 916 | scrolling.tile.onWheel({ deltaY: -1 }); | 939 | scrolling.tile.onWheel({ deltaY: -1 }); |
| 917 | check('wheel request immediately updates displayed start row', scrolling.tile.viewStartRow, 25); | 940 | check('wheel request preserves the currently painted start row', scrolling.tile.viewStartRow, 30); |
| 941 | check('wheel retains the separately requested start row', scrolling.tile.requestedViewStartRow, 25); | ||
| 918 | check('wheel sends scrollback fetch', scrolling.sent[0]?.type, 0x05); | 942 | check('wheel sends scrollback fetch', scrolling.sent[0]?.type, 0x05); |
| 943 | scrolling.tile.canvas.dispatchEvent('pointerdown', pointer(13, 2, 2)); | ||
| 944 | check('quick drag before history reply maps against painted rows', scrolling.tile.selection.anchor.row, 32); | ||
| 945 | scrolling.tile.canvas.dispatchEvent('pointercancel', { pointerId: 13 }); | ||
| 919 | let paintedScroll = 0; | 946 | let paintedScroll = 0; |
| 920 | scrolling.tile.paintScroll = () => { paintedScroll++; }; | 947 | scrolling.tile.paintScroll = () => { paintedScroll++; }; |
| 921 | const chunk = new Uint8Array(6 + 3); | 948 | const chunk = new Uint8Array(6 + 3); |
| @@ -931,6 +958,20 @@ function verifySelectionShell(shell, html) { | |||
| 931 | check('scrollback echo is authoritative for displayed start row', scrolling.tile.viewStartRow, 17); | 958 | check('scrollback echo is authoritative for displayed start row', scrolling.tile.viewStartRow, 17); |
| 932 | check('scrollback echo paints the history viewport', paintedScroll, 1); | 959 | check('scrollback echo paints the history viewport', paintedScroll, 1); |
| 933 | 960 | ||
| 961 | const failedStage = makeTile(); | ||
| 962 | failedStage.tile.scrollPages = 1; | ||
| 963 | failedStage.tile.viewStartRow = 30; | ||
| 964 | failedStage.tile.stage = () => false; | ||
| 965 | failedStage.tile.onMessage(envelope); | ||
| 966 | check('failed scrollback staging preserves painted start row', failedStage.tile.viewStartRow, 30); | ||
| 967 | |||
| 968 | const failedFeed = makeTile(); | ||
| 969 | failedFeed.tile.scrollPages = 1; | ||
| 970 | failedFeed.tile.viewStartRow = 30; | ||
| 971 | failedFeed.setScrollFeedResult(-3); | ||
| 972 | failedFeed.tile.onMessage(envelope); | ||
| 973 | check('failed scrollback decode preserves painted start row', failedFeed.tile.viewStartRow, 30); | ||
| 974 | |||
| 934 | const painted = makeTile(); | 975 | const painted = makeTile(); |
| 935 | const order = []; | 976 | const order = []; |
| 936 | painted.tile.sizeCanvas = () => {}; | 977 | painted.tile.sizeCanvas = () => {}; |
| @@ -947,6 +988,33 @@ function verifySelectionShell(shell, html) { | |||
| 947 | check('history paint retains echoed start row', painted.tile.viewStartRow, 12); | 988 | check('history paint retains echoed start row', painted.tile.viewStartRow, 12); |
| 948 | check('history selection overlay paints after cells', order.join('|'), 'row0|row1|row2|row3|row4|selection'); | 989 | check('history selection overlay paints after cells', order.join('|'), 'row0|row1|row2|row3|row4|selection'); |
| 949 | 990 | ||
| 991 | const partial = makeTile(); | ||
| 992 | partial.tile.sizeCanvas = () => {}; | ||
| 993 | partial.tile.selection = { | ||
| 994 | anchor: { row: 31, col: 2 }, active: { row: 33, col: 4 }, requestId: 1, text: null, | ||
| 995 | }; | ||
| 996 | let dirtyRows = [0]; | ||
| 997 | partial.tile.core.mux_read_viewport = () => dirtyRows.length; | ||
| 998 | partial.tile.core.mux_dirty_row = (i) => dirtyRows[i]; | ||
| 999 | const partialOps = []; | ||
| 1000 | partial.tile.paintRow = (row) => { partialOps.push(`row${row}`); }; | ||
| 1001 | partial.tile.paintCursor = () => { partialOps.push('cursor'); }; | ||
| 1002 | partial.tile.paintSelection = () => { partialOps.push('overlay'); }; | ||
| 1003 | partial.tile.paintLive(); | ||
| 1004 | check( | ||
| 1005 | 'partial live paint restores every selected row before translucent overlay', | ||
| 1006 | partialOps.join('|'), | ||
| 1007 | 'row0|row1|row2|row3|cursor|overlay', | ||
| 1008 | ); | ||
| 1009 | dirtyRows = [2, 4]; | ||
| 1010 | partialOps.length = 0; | ||
| 1011 | partial.tile.paintLive(); | ||
| 1012 | check( | ||
| 1013 | 'successive partial live paint restores selected rows without duplicates', | ||
| 1014 | partialOps.join('|'), | ||
| 1015 | 'row2|row4|row1|row3|cursor|overlay', | ||
| 1016 | ); | ||
| 1017 | |||
| 950 | const overlay = makeTile(); | 1018 | const overlay = makeTile(); |
| 951 | const fills = []; | 1019 | const fills = []; |
| 952 | overlay.tile.ctx = { | 1020 | overlay.tile.ctx = { |
| @@ -1007,6 +1075,134 @@ function verifySelectionShell(shell, html) { | |||
| 1007 | replyEnvelope.set(replyPayload, 6); | 1075 | replyEnvelope.set(replyPayload, 6); |
| 1008 | routed.tile.onMessage(replyEnvelope); | 1076 | routed.tile.onMessage(replyEnvelope); |
| 1009 | check('selection-reply frame routes through semantic client core', routed.tile.selection.text, 'routed'); | 1077 | check('selection-reply frame routes through semantic client core', routed.tile.selection.text, 'routed'); |
| 1078 | |||
| 1079 | const blockedCopy = makeTile(); | ||
| 1080 | blockedCopy.tile.pendingClipboard = 'unrelated OSC 52 text'; | ||
| 1081 | blockedCopy.tile.clipboardVersion = 1; | ||
| 1082 | blockedCopy.tile.copyUiOwner = 'clipboard'; | ||
| 1083 | blockedCopy.tile.selection = { | ||
| 1084 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 71, text: null, | ||
| 1085 | }; | ||
| 1086 | blockedCopy.setResult(71, 3, []); | ||
| 1087 | blockedCopy.tile.onSelectionReply(); | ||
| 1088 | const blockedWrites = []; | ||
| 1089 | h.navigator.clipboard = { writeText: (text) => { blockedWrites.push(text); return Promise.resolve(); } }; | ||
| 1090 | blockedCopy.tile.copyButton.dispatchEvent('click', { stopPropagation() {} }); | ||
| 1091 | await flushPromises(); | ||
| 1092 | check('selection failure owns the shared copy control', blockedCopy.tile.copyUiOwner, 'selection'); | ||
| 1093 | check('selection failure button cannot copy unrelated OSC 52 text', blockedWrites.length, 0); | ||
| 1094 | check('selection failure leaves unrelated OSC 52 text pending', blockedCopy.tile.pendingClipboard, 'unrelated OSC 52 text'); | ||
| 1095 | |||
| 1096 | const inFlightUi = makeTile(); | ||
| 1097 | inFlightUi.tile.pendingClipboard = 'in flight'; | ||
| 1098 | inFlightUi.tile.clipboardVersion = 1; | ||
| 1099 | inFlightUi.tile.copyUiOwner = 'clipboard'; | ||
| 1100 | const inFlightWrite = deferred(); | ||
| 1101 | h.navigator.clipboard = { writeText: () => inFlightWrite.promise }; | ||
| 1102 | const inFlightRun = inFlightUi.tile.tryClipboardWrite(1, true); | ||
| 1103 | inFlightUi.tile.selection = { | ||
| 1104 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 72, text: null, | ||
| 1105 | }; | ||
| 1106 | inFlightUi.setResult(72, 2, []); | ||
| 1107 | inFlightUi.tile.onSelectionReply(); | ||
| 1108 | inFlightWrite.resolve(); | ||
| 1109 | await inFlightRun; | ||
| 1110 | check( | ||
| 1111 | 'settled in-flight OSC 52 write cannot overwrite selection failure status', | ||
| 1112 | `${inFlightUi.tile.copyButton.className}|${inFlightUi.tile.copyButton.textContent}`, | ||
| 1113 | 'copy-request on error|Selection unavailable', | ||
| 1114 | ); | ||
| 1115 | |||
| 1116 | const timerUi = makeTile(); | ||
| 1117 | timerUi.tile.pendingClipboard = 'copied before selection failure'; | ||
| 1118 | timerUi.tile.clipboardVersion = 1; | ||
| 1119 | timerUi.tile.copyUiOwner = 'clipboard'; | ||
| 1120 | h.navigator.clipboard = { writeText: () => Promise.resolve() }; | ||
| 1121 | await timerUi.tile.tryClipboardWrite(1, true); | ||
| 1122 | const oldClipboardTimer = h.timers.at(-1); | ||
| 1123 | timerUi.tile.selection = { | ||
| 1124 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 73, text: null, | ||
| 1125 | }; | ||
| 1126 | timerUi.setResult(73, 1, []); | ||
| 1127 | timerUi.tile.onSelectionReply(); | ||
| 1128 | oldClipboardTimer.fn(); | ||
| 1129 | check( | ||
| 1130 | 'old OSC 52 feedback timer cannot overwrite selection failure status', | ||
| 1131 | `${timerUi.tile.copyButton.className}|${timerUi.tile.copyButton.textContent}`, | ||
| 1132 | 'copy-request on error|Selection unavailable', | ||
| 1133 | ); | ||
| 1134 | |||
| 1135 | const clearedUi = makeTile(); | ||
| 1136 | clearedUi.tile.selection = { | ||
| 1137 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 74, text: null, | ||
| 1138 | }; | ||
| 1139 | clearedUi.setResult(74, 3, []); | ||
| 1140 | clearedUi.tile.onSelectionReply(); | ||
| 1141 | clearedUi.tile.clearSelection(false); | ||
| 1142 | check('clearing failed selection removes stale error UI', `${clearedUi.tile.copyButton.className}|${clearedUi.tile.copyButton.textContent}`, 'copy-request|Copy'); | ||
| 1143 | |||
| 1144 | const successfulUi = makeTile(); | ||
| 1145 | successfulUi.tile.selection = { | ||
| 1146 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 75, text: null, | ||
| 1147 | }; | ||
| 1148 | successfulUi.setResult(75, 3, []); | ||
| 1149 | successfulUi.tile.onSelectionReply(); | ||
| 1150 | successfulUi.setResult(75, 0, Buffer.from('available')); | ||
| 1151 | successfulUi.tile.onSelectionReply(); | ||
| 1152 | check('successful selection removes stale unavailable UI', `${successfulUi.tile.copyButton.className}|${successfulUi.tile.copyButton.textContent}`, 'copy-request|Copy'); | ||
| 1153 | |||
| 1154 | const newSelectionUi = makeTile(); | ||
| 1155 | newSelectionUi.tile.selection = { | ||
| 1156 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 76, text: null, | ||
| 1157 | }; | ||
| 1158 | newSelectionUi.setResult(76, 3, []); | ||
| 1159 | newSelectionUi.tile.onSelectionReply(); | ||
| 1160 | newSelectionUi.tile.canvas.dispatchEvent('pointerdown', pointer(17, 1, 1)); | ||
| 1161 | check('starting a new selection removes stale unavailable UI', `${newSelectionUi.tile.copyButton.className}|${newSelectionUi.tile.copyButton.textContent}`, 'copy-request|Copy'); | ||
| 1162 | newSelectionUi.tile.canvas.dispatchEvent('pointercancel', { pointerId: 17 }); | ||
| 1163 | |||
| 1164 | const wrap = makeTile(); | ||
| 1165 | wrap.tile.nextSelectionId = 0xffffffff; | ||
| 1166 | wrap.tile.canvas.dispatchEvent('pointerdown', pointer(14, 1, 1)); | ||
| 1167 | wrap.tile.canvas.dispatchEvent('pointermove', pointer(14, 2, 1)); | ||
| 1168 | wrap.tile.canvas.dispatchEvent('pointerup', pointer(14, 2, 1)); | ||
| 1169 | check('selection request ID naturally wraps from max u32 to zero', wrap.requestCalls[0]?.[0], 0); | ||
| 1170 | check('wrapped zero remains the retained correlation ID', wrap.tile.selection.requestId, 0); | ||
| 1171 | wrap.setResult(0, 0, Buffer.from('wrapped')); | ||
| 1172 | wrap.tile.onSelectionReply(); | ||
| 1173 | check('wrapped zero reply correlates normally', wrap.tile.selection.text, 'wrapped'); | ||
| 1174 | |||
| 1175 | const reset = makeTile(); | ||
| 1176 | reset.tile.selection = { | ||
| 1177 | anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 7, text: 'resolved old session', | ||
| 1178 | }; | ||
| 1179 | reset.tile.drag = { pointerId: 15, moved: true }; | ||
| 1180 | reset.tile.canvas.setPointerCapture(15); | ||
| 1181 | reset.tile.selectionScrollTimer = h.context.setInterval(() => {}, 120); | ||
| 1182 | const resetTimer = reset.tile.selectionScrollTimer; | ||
| 1183 | reset.tile.resetCore('selection test'); | ||
| 1184 | check('destructive core reset clears resolved selection', reset.tile.selection, null); | ||
| 1185 | check('destructive core reset clears drag state', reset.tile.drag, null); | ||
| 1186 | check('destructive core reset clears pointer position', reset.tile.lastPointerY, null); | ||
| 1187 | check('destructive core reset stops selection timer', h.intervals[resetTimer - 1]?.cleared, true); | ||
| 1188 | |||
| 1189 | const reconnect = makeTile(); | ||
| 1190 | reconnect.tile.canvas.dispatchEvent('pointerdown', pointer(16, 1, 1)); | ||
| 1191 | const reconnectTimer = reconnect.tile.selectionScrollTimer; | ||
| 1192 | reconnect.tile.selection.requestId = 44; | ||
| 1193 | reconnect.tile.connect(); | ||
| 1194 | reconnect.tile.ws.onopen(); | ||
| 1195 | check('new socket clears pending selection from prior connection', reconnect.tile.selection, null); | ||
| 1196 | check('new socket clears prior drag state', reconnect.tile.drag, null); | ||
| 1197 | check('new socket clears prior pointer position', reconnect.tile.lastPointerY, null); | ||
| 1198 | check('new socket stops prior selection timer', h.intervals[reconnectTimer - 1]?.cleared, true); | ||
| 1199 | |||
| 1200 | const reattach = makeTile(); | ||
| 1201 | reattach.tile.selection = { | ||
| 1202 | anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 45, text: 'old attach', | ||
| 1203 | }; | ||
| 1204 | reattach.tile.sendAttach(false); | ||
| 1205 | check('reattach clears selection text from prior session', reattach.tile.selection, null); | ||
| 1010 | } | 1206 | } |
| 1011 | 1207 | ||
| 1012 | // --- wire builders (layouts golden-pinned in protocol.zig) --- | 1208 | // --- wire builders (layouts golden-pinned in protocol.zig) --- |
| @@ -1436,7 +1632,7 @@ async function main() { | |||
| 1436 | const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); | 1632 | const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); |
| 1437 | const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8'); | 1633 | const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8'); |
| 1438 | await verifyClipboardShell(shell, html); | 1634 | await verifyClipboardShell(shell, html); |
| 1439 | verifySelectionShell(shell, html); | 1635 | await verifySelectionShell(shell, html); |
| 1440 | 1636 | ||
| 1441 | // Mutation fixture for the source checks below. A correct-looking route | 1637 | // Mutation fixture for the source checks below. A correct-looking route |
| 1442 | // in either kind of comment must be invisible, while live literal | 1638 | // in either kind of comment must be invisible, while live literal |