cfc2e619
feat: add retained web terminal selection
a73x 2026-08-18 12:27
Commit message
web/index.html
| Old | New | ||
|---|---|---|---|
| @@ -53,7 +53,7 @@ | |||
| 53 | .tile.zoomed { | 53 | .tile.zoomed { |
| 54 | position: fixed; inset: 3vh 4vw; z-index: 20; cursor: default; | 54 | position: fixed; inset: 3vh 4vw; z-index: 20; cursor: default; |
| 55 | } | 55 | } |
| 56 | .tile.zoomed canvas { margin: 0 auto; } | 56 | .tile.zoomed canvas { margin: 0 auto; cursor: text; touch-action: none; } |
| 57 | /* Below the zoomed tile, above the wall: the click target for unzoom. */ | 57 | /* Below the zoomed tile, above the wall: the click target for unzoom. */ |
| 58 | #shade { | 58 | #shade { |
| 59 | display: none; position: fixed; inset: 0; z-index: 10; background: #000a; | 59 | display: none; position: fixed; inset: 0; z-index: 10; background: #000a; |
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -11,11 +11,12 @@ | |||
| 11 | // Wire bytes (protocol.zig MsgType / webhub.zig envelope). | 11 | // Wire bytes (protocol.zig MsgType / webhub.zig envelope). |
| 12 | const MSG = { | 12 | const MSG = { |
| 13 | attach: 0x01, input: 0x02, resize: 0x03, detach: 0x04, fetch_scrollback: 0x05, | 13 | attach: 0x01, input: 0x02, resize: 0x03, detach: 0x04, fetch_scrollback: 0x05, |
| 14 | selection_req: 0x0b, | ||
| 14 | snapshot: 0x81, exit_status: 0x82, scrollback_chunk: 0x85, delta: 0x87, | 15 | snapshot: 0x81, exit_status: 0x82, scrollback_chunk: 0x85, delta: 0x87, |
| 15 | pty_mode: 0x88, term_modes: 0x8d, term_event: 0x8f, | 16 | pty_mode: 0x88, term_modes: 0x8d, term_event: 0x8f, selection_reply: 0x90, |
| 16 | }; | 17 | }; |
| 17 | const CLIENT_ACTION = { | 18 | const CLIENT_ACTION = { |
| 18 | ignored: 0, terminalModes: 1, clipboard: 2, bell: 3, | 19 | ignored: 0, terminalModes: 1, clipboard: 2, bell: 3, selection: 4, |
| 19 | }; | 20 | }; |
| 20 | const ENV_FRAME = 0x00, ENV_CONTROL = 0x01; | 21 | const ENV_FRAME = 0x00, ENV_CONTROL = 0x01; |
| 21 | 22 | ||
| @@ -130,6 +131,12 @@ class Tile { | |||
| 130 | this.pendingClipboard = null; | 131 | this.pendingClipboard = null; |
| 131 | this.clipboardVersion = 0; | 132 | this.clipboardVersion = 0; |
| 132 | this.clipboardWriteActive = false; | 133 | this.clipboardWriteActive = false; |
| 134 | this.viewStartRow = 0; | ||
| 135 | this.selection = null; // {anchor, active, requestId, text} | ||
| 136 | this.nextSelectionId = 0; | ||
| 137 | this.drag = null; | ||
| 138 | this.lastPointerY = null; | ||
| 139 | this.selectionScrollTimer = null; | ||
| 133 | 140 | ||
| 134 | this.el = document.createElement('div'); | 141 | this.el = document.createElement('div'); |
| 135 | this.el.className = 'tile'; | 142 | this.el.className = 'tile'; |
| @@ -154,6 +161,12 @@ class Tile { | |||
| 154 | if (this.zoomed) { ev.preventDefault(); this.onWheel(ev); } | 161 | if (this.zoomed) { ev.preventDefault(); this.onWheel(ev); } |
| 155 | // Wall tiles don't scroll (spec): the event falls through to the page. | 162 | // Wall tiles don't scroll (spec): the event falls through to the page. |
| 156 | }, { passive: false }); | 163 | }, { passive: false }); |
| 164 | this.canvas.addEventListener('pointerdown', (ev) => this.beginSelection(ev)); | ||
| 165 | this.canvas.addEventListener('pointermove', (ev) => this.moveSelection(ev)); | ||
| 166 | this.canvas.addEventListener('pointerup', (ev) => this.endSelection(ev)); | ||
| 167 | this.canvas.addEventListener('pointercancel', (ev) => { | ||
| 168 | if (this.drag && ev.pointerId === this.drag.pointerId) this.clearSelection(); | ||
| 169 | }); | ||
| 157 | } | 170 | } |
| 158 | 171 | ||
| 159 | async start() { | 172 | async start() { |
| @@ -435,16 +448,21 @@ class Tile { | |||
| 435 | } | 448 | } |
| 436 | case MSG.scrollback_chunk: { | 449 | case MSG.scrollback_chunk: { |
| 437 | if (this.scrollPages === 0 || payload.length < 6) return; | 450 | if (this.scrollPages === 0 || payload.length < 6) return; |
| 451 | this.viewStartRow = new DataView( | ||
| 452 | payload.buffer, payload.byteOffset, 6, | ||
| 453 | ).getUint32(0, true); | ||
| 438 | const rows = payload.subarray(6); // echoed start+count stripped | 454 | const rows = payload.subarray(6); // echoed start+count stripped |
| 439 | if (!this.stage(rows)) return; | 455 | if (!this.stage(rows)) return; |
| 440 | if (this.core.mux_scroll_feed(rows.length) === 0) this.paintScroll(); | 456 | if (this.core.mux_scroll_feed(rows.length) === 0) this.paintScroll(); |
| 441 | return; | 457 | return; |
| 442 | } | 458 | } |
| 443 | case MSG.term_modes: | 459 | case MSG.term_modes: |
| 444 | case MSG.term_event: { | 460 | case MSG.term_event: |
| 461 | case MSG.selection_reply: { | ||
| 445 | if (!this.stage(payload)) return; | 462 | if (!this.stage(payload)) return; |
| 446 | const action = this.core.mux_client_frame(type, payload.length); | 463 | const action = this.core.mux_client_frame(type, payload.length); |
| 447 | if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect(); | 464 | if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect(); |
| 465 | else if (action === CLIENT_ACTION.selection) this.onSelectionReply(); | ||
| 448 | return; | 466 | return; |
| 449 | } | 467 | } |
| 450 | case MSG.pty_mode: // no prediction in the web client (spec non-goal) | 468 | case MSG.pty_mode: // no prediction in the web client (spec non-goal) |
| @@ -520,6 +538,104 @@ class Tile { | |||
| 520 | return this.tryClipboardWrite(this.clipboardVersion, false); | 538 | return this.tryClipboardWrite(this.clipboardVersion, false); |
| 521 | } | 539 | } |
| 522 | 540 | ||
| 541 | onSelectionReply() { | ||
| 542 | if (!this.selection) return; | ||
| 543 | // A Zig u32 result reaches JavaScript as a signed WASM i32. | ||
| 544 | if ((this.core.mux_selection_id() >>> 0) !== this.selection.requestId) return; | ||
| 545 | if (this.core.mux_selection_status() !== 0) { | ||
| 546 | this.selection.text = null; | ||
| 547 | this.copyButton.className = 'copy-request on error'; | ||
| 548 | this.copyButton.textContent = 'Selection unavailable'; | ||
| 549 | return; | ||
| 550 | } | ||
| 551 | const ptr = this.core.mux_selection_ptr(); | ||
| 552 | const len = this.core.mux_selection_len(); | ||
| 553 | // Selection text borrows the staging buffer. Snapshot it before any | ||
| 554 | // other WASM call or asynchronous clipboard operation can replace it. | ||
| 555 | const bytes = new Uint8Array(this.core.memory.buffer).slice(ptr, ptr + len); | ||
| 556 | try { | ||
| 557 | this.selection.text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); | ||
| 558 | } catch (_) { | ||
| 559 | this.selection.text = null; | ||
| 560 | } | ||
| 561 | } | ||
| 562 | |||
| 563 | // --- retained selection --- | ||
| 564 | cellAtPointer(ev, clampY = true) { | ||
| 565 | const rect = this.canvas.getBoundingClientRect(); | ||
| 566 | const cols = this.core.mux_cols(), rows = this.core.mux_rows(); | ||
| 567 | const x = Math.max(0, Math.min(cols - 1, | ||
| 568 | Math.floor((ev.clientX - rect.left) / this.drawScale / METRICS.w))); | ||
| 569 | let y = Math.floor((ev.clientY - rect.top) / this.drawScale / METRICS.h); | ||
| 570 | if (clampY) y = Math.max(0, Math.min(rows - 1, y)); | ||
| 571 | return { col: x, viewRow: y, row: this.viewStartRow + y }; | ||
| 572 | } | ||
| 573 | |||
| 574 | orderedSelection() { | ||
| 575 | if (!this.selection) return null; | ||
| 576 | const a = this.selection.anchor, b = this.selection.active; | ||
| 577 | if (a.row < b.row || (a.row === b.row && a.col <= b.col)) return [a, b]; | ||
| 578 | return [b, a]; | ||
| 579 | } | ||
| 580 | |||
| 581 | clearSelection(repaint = true) { | ||
| 582 | this.selection = null; | ||
| 583 | this.drag = null; | ||
| 584 | if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer); | ||
| 585 | this.selectionScrollTimer = null; | ||
| 586 | if (repaint && this.core) this.reflow(); | ||
| 587 | } | ||
| 588 | |||
| 589 | beginSelection(ev) { | ||
| 590 | if (!this.zoomed || ev.button !== 0) return; | ||
| 591 | ev.preventDefault(); | ||
| 592 | const point = this.cellAtPointer(ev); | ||
| 593 | this.clearSelection(false); | ||
| 594 | this.selection = { anchor: point, active: point, requestId: 0, text: null }; | ||
| 595 | this.drag = { pointerId: ev.pointerId, moved: false }; | ||
| 596 | this.lastPointerY = ev.clientY; | ||
| 597 | this.canvas.setPointerCapture(ev.pointerId); | ||
| 598 | this.selectionScrollTimer = setInterval(() => this.autoScrollSelection(), 120); | ||
| 599 | this.reflow(); | ||
| 600 | } | ||
| 601 | |||
| 602 | moveSelection(ev) { | ||
| 603 | if (!this.drag || ev.pointerId !== this.drag.pointerId) return; | ||
| 604 | ev.preventDefault(); | ||
| 605 | this.lastPointerY = ev.clientY; | ||
| 606 | const point = this.cellAtPointer(ev); | ||
| 607 | if (point.row !== this.selection.active.row || point.col !== this.selection.active.col) | ||
| 608 | this.drag.moved = true; | ||
| 609 | this.selection.active = point; | ||
| 610 | this.reflow(); | ||
| 611 | } | ||
| 612 | |||
| 613 | endSelection(ev) { | ||
| 614 | if (!this.drag || ev.pointerId !== this.drag.pointerId) return; | ||
| 615 | ev.preventDefault(); | ||
| 616 | const moved = this.drag.moved; | ||
| 617 | if (this.canvas.hasPointerCapture(ev.pointerId)) this.canvas.releasePointerCapture(ev.pointerId); | ||
| 618 | if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer); | ||
| 619 | this.selectionScrollTimer = null; | ||
| 620 | this.drag = null; | ||
| 621 | if (!moved) { this.clearSelection(); return; } | ||
| 622 | |||
| 623 | this.nextSelectionId = (this.nextSelectionId + 1) >>> 0; | ||
| 624 | if (this.nextSelectionId === 0) this.nextSelectionId = 1; | ||
| 625 | this.selection.requestId = this.nextSelectionId; | ||
| 626 | const a = this.selection.anchor, b = this.selection.active; | ||
| 627 | const n = this.core.mux_selection_request( | ||
| 628 | this.nextSelectionId, a.row, a.col, b.row, b.col, | ||
| 629 | ); | ||
| 630 | if (n === 16) this.sendFrame(MSG.selection_req, this.outBytes()); | ||
| 631 | else this.clearSelection(); | ||
| 632 | } | ||
| 633 | |||
| 634 | // Task 6 gives this timer its scroll policy. Keeping the callback valid | ||
| 635 | // here makes the pointer lifecycle complete without changing viewport | ||
| 636 | // behavior in this slice. | ||
| 637 | autoScrollSelection() {} | ||
| 638 | |||
| 523 | // --- painting --- | 639 | // --- painting --- |
| 524 | // A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it | 640 | // A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it |
| 525 | // must be drawn small. Two ways to do that, and only one is legible: | 641 | // must be drawn small. Two ways to do that, and only one is legible: |
| @@ -566,16 +682,19 @@ class Tile { | |||
| 566 | else this.paintScroll(); | 682 | else this.paintScroll(); |
| 567 | } | 683 | } |
| 568 | paintLive() { | 684 | paintLive() { |
| 685 | this.viewStartRow = this.core.mux_history_rows(); | ||
| 569 | this.sizeCanvas(); | 686 | this.sizeCanvas(); |
| 570 | const n = this.core.mux_read_viewport(); | 687 | const n = this.core.mux_read_viewport(); |
| 571 | for (let i = 0; i < n; i++) this.paintRow(this.core.mux_dirty_row(i)); | 688 | for (let i = 0; i < n; i++) this.paintRow(this.core.mux_dirty_row(i)); |
| 572 | this.paintCursor(); | 689 | this.paintCursor(); |
| 690 | this.paintSelection(); | ||
| 573 | } | 691 | } |
| 574 | paintScroll() { | 692 | paintScroll() { |
| 575 | this.sizeCanvas(); | 693 | this.sizeCanvas(); |
| 576 | this.core.mux_read_scroll_viewport(); | 694 | this.core.mux_read_scroll_viewport(); |
| 577 | const rows = this.core.mux_rows(); | 695 | const rows = this.core.mux_rows(); |
| 578 | for (let y = 0; y < rows; y++) this.paintRow(y); | 696 | for (let y = 0; y < rows; y++) this.paintRow(y); |
| 697 | this.paintSelection(); | ||
| 579 | } | 698 | } |
| 580 | paintRow(y) { | 699 | paintRow(y) { |
| 581 | const cols = this.core.mux_cols(); | 700 | const cols = this.core.mux_cols(); |
| @@ -625,6 +744,25 @@ class Tile { | |||
| 625 | this.ctx.fillRect(x * METRICS.w, y * METRICS.h, METRICS.w, METRICS.h); | 744 | this.ctx.fillRect(x * METRICS.w, y * METRICS.h, METRICS.w, METRICS.h); |
| 626 | } | 745 | } |
| 627 | 746 | ||
| 747 | paintSelection() { | ||
| 748 | const ordered = this.orderedSelection(); | ||
| 749 | if (!ordered) return; | ||
| 750 | const [start, end] = ordered; | ||
| 751 | const cols = this.core.mux_cols(), rows = this.core.mux_rows(); | ||
| 752 | this.ctx.fillStyle = '#6ab0e055'; | ||
| 753 | for (let y = 0; y < rows; y++) { | ||
| 754 | const screenRow = this.viewStartRow + y; | ||
| 755 | if (screenRow < start.row || screenRow > end.row) continue; | ||
| 756 | const first = screenRow === start.row ? start.col : 0; | ||
| 757 | const last = screenRow === end.row ? end.col : cols - 1; | ||
| 758 | if (last < first) continue; | ||
| 759 | this.ctx.fillRect( | ||
| 760 | first * METRICS.w, y * METRICS.h, | ||
| 761 | (last - first + 1) * METRICS.w, METRICS.h, | ||
| 762 | ); | ||
| 763 | } | ||
| 764 | } | ||
| 765 | |||
| 628 | // --- scrollback --- | 766 | // --- scrollback --- |
| 629 | onWheel(ev) { | 767 | onWheel(ev) { |
| 630 | const rows = this.core.mux_rows(); | 768 | const rows = this.core.mux_rows(); |
| @@ -644,6 +782,7 @@ class Tile { | |||
| 644 | } | 782 | } |
| 645 | this.renderBadge(); | 783 | this.renderBadge(); |
| 646 | const start = this.core.mux_scroll_start(this.scrollPages, rows); | 784 | const start = this.core.mux_scroll_start(this.scrollPages, rows); |
| 785 | this.viewStartRow = start; | ||
| 647 | const p = new Uint8Array(6); | 786 | const p = new Uint8Array(6); |
| 648 | const dv = new DataView(p.buffer); | 787 | const dv = new DataView(p.buffer); |
| 649 | dv.setUint32(0, start, true); | 788 | dv.setUint32(0, start, true); |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -336,6 +336,8 @@ function browserShell(source) { | |||
| 336 | this.value = ''; | 336 | this.value = ''; |
| 337 | this.clientWidth = 640; | 337 | this.clientWidth = 640; |
| 338 | this.type = ''; | 338 | this.type = ''; |
| 339 | this.rect = { left: 0, top: 0, width: 640, height: 480 }; | ||
| 340 | this.capturedPointers = new Set(); | ||
| 339 | } | 341 | } |
| 340 | set innerHTML(value) { | 342 | set innerHTML(value) { |
| 341 | this._innerHTML = value; | 343 | this._innerHTML = value; |
| @@ -378,7 +380,10 @@ function browserShell(source) { | |||
| 378 | } | 380 | } |
| 379 | addEventListener(type, fn) { this.listeners.set(type, fn); } | 381 | addEventListener(type, fn) { this.listeners.set(type, fn); } |
| 380 | dispatchEvent(type, event) { return this.listeners.get(type)?.(event); } | 382 | dispatchEvent(type, event) { return this.listeners.get(type)?.(event); } |
| 381 | getBoundingClientRect() { return { width: 640, height: 480 }; } | 383 | getBoundingClientRect() { return this.rect; } |
| 384 | setPointerCapture(pointerId) { this.capturedPointers.add(pointerId); } | ||
| 385 | hasPointerCapture(pointerId) { return this.capturedPointers.has(pointerId); } | ||
| 386 | releasePointerCapture(pointerId) { this.capturedPointers.delete(pointerId); } | ||
| 382 | getContext() { | 387 | getContext() { |
| 383 | return { | 388 | return { |
| 384 | measureText: () => ({ width: 8, fontBoundingBoxAscent: 11, fontBoundingBoxDescent: 3 }), | 389 | measureText: () => ({ width: 8, fontBoundingBoxAscent: 11, fontBoundingBoxDescent: 3 }), |
| @@ -403,12 +408,15 @@ function browserShell(source) { | |||
| 403 | dispatchEvent(type, event) { return documentListeners.get(type)?.(event); }, | 408 | dispatchEvent(type, event) { return documentListeners.get(type)?.(event); }, |
| 404 | }; | 409 | }; |
| 405 | const timers = []; | 410 | const timers = []; |
| 411 | const intervals = []; | ||
| 406 | const navigator = { clipboard: undefined }; | 412 | const navigator = { clipboard: undefined }; |
| 407 | const context = vm.createContext({ | 413 | const context = vm.createContext({ |
| 408 | ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder, | 414 | ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder, |
| 409 | Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document, | 415 | Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document, |
| 410 | location: { host: 'verify.invalid' }, navigator, | 416 | location: { host: 'verify.invalid' }, navigator, |
| 411 | setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, | 417 | setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, |
| 418 | setInterval: (fn, ms) => { intervals.push({ fn, ms, cleared: false }); return intervals.length; }, | ||
| 419 | clearInterval: (id) => { if (intervals[id - 1]) intervals[id - 1].cleared = true; }, | ||
| 412 | window: { addEventListener() {}, devicePixelRatio: 1 }, | 420 | window: { addEventListener() {}, devicePixelRatio: 1 }, |
| 413 | WebSocket: class { static OPEN = 1; }, | 421 | WebSocket: class { static OPEN = 1; }, |
| 414 | atob: globalThis.atob, | 422 | atob: globalThis.atob, |
| @@ -418,7 +426,7 @@ function browserShell(source) { | |||
| 418 | 'Tile, clipboardText: typeof clipboardText === "function" ? clipboardText : undefined, ' + | 426 | 'Tile, clipboardText: typeof clipboardText === "function" ? clipboardText : undefined, ' + |
| 419 | 'unzoom, setZoomedTile(tile) { zoomedTile = tile; }\n' + | 427 | 'unzoom, setZoomedTile(tile) { zoomedTile = tile; }\n' + |
| 420 | '};').runInContext(context); | 428 | '};').runInContext(context); |
| 421 | return { ...context.__verify, context, document, elements, navigator, timers }; | 429 | return { ...context.__verify, context, document, elements, navigator, timers, intervals }; |
| 422 | } | 430 | } |
| 423 | 431 | ||
| 424 | async function flushPromises() { | 432 | async function flushPromises() { |
| @@ -702,6 +710,305 @@ async function verifyClipboardShell(shell, html) { | |||
| 702 | check('page gives failed copy a distinct style', errorRule.test(executableHtmlCss), true); | 710 | check('page gives failed copy a distinct style', errorRule.test(executableHtmlCss), true); |
| 703 | } | 711 | } |
| 704 | 712 | ||
| 713 | function verifySelectionShell(shell, html) { | ||
| 714 | const h = browserShell(shell); | ||
| 715 | const wall = h.document.createElement('div'); | ||
| 716 | const tile = new h.Tile(3, 'selection', wall, ''); | ||
| 717 | const initialState = tile.viewStartRow === 0 | ||
| 718 | && tile.selection === null | ||
| 719 | && tile.nextSelectionId === 0 | ||
| 720 | && tile.drag === null | ||
| 721 | && tile.lastPointerY === null | ||
| 722 | && tile.selectionScrollTimer === null; | ||
| 723 | check('tile initializes retained selection state', initialState, true); | ||
| 724 | check( | ||
| 725 | 'tile binds all pointer selection events', | ||
| 726 | ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'] | ||
| 727 | .every((name) => tile.canvas.listeners.has(name)), | ||
| 728 | true, | ||
| 729 | ); | ||
| 730 | |||
| 731 | const executableHtmlCss = executableCss(html); | ||
| 732 | const selectionCanvasRule = /\.tile\.zoomed\s+canvas\s*\{(?=[^}]*\bcursor\s*:\s*text\s*;)(?=[^}]*\btouch-action\s*:\s*none\s*;)[^}]*\}/s; | ||
| 733 | const commentedSelectionRule = executableCss(`/* | ||
| 734 | .tile.zoomed canvas { cursor: text; touch-action: none; } | ||
| 735 | */`); | ||
| 736 | check('CSS masker rejects commented selection-canvas decoy', selectionCanvasRule.test(commentedSelectionRule), false); | ||
| 737 | check('zoomed canvas uses a text cursor and owns touch dragging', selectionCanvasRule.test(executableHtmlCss), true); | ||
| 738 | |||
| 739 | const selectionMethods = [ | ||
| 740 | 'cellAtPointer', 'orderedSelection', 'clearSelection', 'paintSelection', | ||
| 741 | 'beginSelection', 'moveSelection', 'endSelection', 'onSelectionReply', | ||
| 742 | ]; | ||
| 743 | check( | ||
| 744 | 'tile exposes retained-selection behavior', | ||
| 745 | selectionMethods.every((name) => typeof tile[name] === 'function'), | ||
| 746 | true, | ||
| 747 | ); | ||
| 748 | if (!initialState || !selectionMethods.every((name) => typeof tile[name] === 'function')) return; | ||
| 749 | |||
| 750 | const makeTile = () => { | ||
| 751 | const selected = new h.Tile(4, 'selection fixture', h.document.createElement('div'), ''); | ||
| 752 | const memory = { buffer: new ArrayBuffer(1024) }; | ||
| 753 | let requestResult = 16; | ||
| 754 | let result = { id: 0, status: 3, ptr: 96, len: 0 }; | ||
| 755 | const requestCalls = []; | ||
| 756 | selected.core = { | ||
| 757 | memory, | ||
| 758 | mux_cols: () => 10, | ||
| 759 | mux_rows: () => 5, | ||
| 760 | mux_history_rows: () => 30, | ||
| 761 | mux_mark_all_dirty() {}, | ||
| 762 | mux_read_viewport: () => 0, | ||
| 763 | mux_read_scroll_viewport: () => 5, | ||
| 764 | mux_dirty_row: (i) => i, | ||
| 765 | mux_cursor_x: () => 0, | ||
| 766 | mux_cursor_y: () => 0, | ||
| 767 | mux_viewport_ptr: () => 512, | ||
| 768 | mux_input_cap: () => 1024, | ||
| 769 | mux_input_ptr: () => 0, | ||
| 770 | mux_output_ptr: () => 256, | ||
| 771 | mux_output_len: () => requestResult === 16 ? 16 : 0, | ||
| 772 | mux_scroll_start: (pages, rows) => 30 - pages * rows, | ||
| 773 | mux_scroll_feed: () => 0, | ||
| 774 | mux_client_frame: (type) => type === 0x90 ? 4 : 0, | ||
| 775 | mux_selection_id: () => result.id | 0, | ||
| 776 | mux_selection_status: () => result.status, | ||
| 777 | mux_selection_ptr: () => result.ptr, | ||
| 778 | mux_selection_len: () => result.len, | ||
| 779 | mux_selection_request: (id, ar, ac, br, bc) => { | ||
| 780 | requestCalls.push([id >>> 0, ar, ac, br, bc]); | ||
| 781 | if (requestResult !== 16) return requestResult; | ||
| 782 | const view = new DataView(memory.buffer, 256, 16); | ||
| 783 | view.setUint32(0, id, true); | ||
| 784 | view.setUint32(4, ar, true); | ||
| 785 | view.setUint16(8, ac, true); | ||
| 786 | view.setUint32(10, br, true); | ||
| 787 | view.setUint16(14, bc, true); | ||
| 788 | return 16; | ||
| 789 | }, | ||
| 790 | }; | ||
| 791 | selected.zoomed = true; | ||
| 792 | selected.drawScale = 1; | ||
| 793 | selected.canvas.rect = { left: 10, top: 20, width: 80, height: 70 }; | ||
| 794 | const sent = []; | ||
| 795 | selected.sendFrame = (type, payload) => sent.push({ type, payload: Uint8Array.from(payload) }); | ||
| 796 | let reflows = 0; | ||
| 797 | selected.reflow = () => { reflows++; }; | ||
| 798 | return { | ||
| 799 | tile: selected, memory, requestCalls, sent, | ||
| 800 | setRequestResult(value) { requestResult = value; }, | ||
| 801 | setResult(id, status, textBytes) { | ||
| 802 | const bytes = Uint8Array.from(textBytes); | ||
| 803 | new Uint8Array(memory.buffer).set(bytes, 96); | ||
| 804 | result = { id, status, ptr: 96, len: bytes.length }; | ||
| 805 | }, | ||
| 806 | reflows: () => reflows, | ||
| 807 | }; | ||
| 808 | }; | ||
| 809 | const pointer = (pointerId, col, viewRow, button = 0) => { | ||
| 810 | let prevented = false; | ||
| 811 | return { | ||
| 812 | pointerId, button, | ||
| 813 | clientX: 10 + col * 8 + 1, | ||
| 814 | clientY: 20 + viewRow * 14 + 1, | ||
| 815 | preventDefault() { prevented = true; }, | ||
| 816 | wasPrevented: () => prevented, | ||
| 817 | }; | ||
| 818 | }; | ||
| 819 | |||
| 820 | const coordinate = makeTile(); | ||
| 821 | coordinate.tile.viewStartRow = 40; | ||
| 822 | check( | ||
| 823 | 'pointer coordinates include the retained viewport start', | ||
| 824 | JSON.stringify(coordinate.tile.cellAtPointer(pointer(1, 3, 2))), | ||
| 825 | JSON.stringify({ col: 3, viewRow: 2, row: 42 }), | ||
| 826 | ); | ||
| 827 | check( | ||
| 828 | 'pointer coordinates clamp outside the visible grid', | ||
| 829 | JSON.stringify(coordinate.tile.cellAtPointer({ clientX: -50, clientY: 999 })), | ||
| 830 | JSON.stringify({ col: 0, viewRow: 4, row: 44 }), | ||
| 831 | ); | ||
| 832 | |||
| 833 | const ignored = makeTile(); | ||
| 834 | ignored.tile.zoomed = false; | ||
| 835 | ignored.tile.canvas.dispatchEvent('pointerdown', pointer(1, 2, 2)); | ||
| 836 | ignored.tile.zoomed = true; | ||
| 837 | ignored.tile.canvas.dispatchEvent('pointerdown', pointer(2, 2, 2, 1)); | ||
| 838 | check('unzoomed and non-left pointer downs are ignored', ignored.tile.selection, null); | ||
| 839 | check('ignored pointer downs capture nothing', ignored.tile.canvas.capturedPointers.size, 0); | ||
| 840 | |||
| 841 | const forward = makeTile(); | ||
| 842 | forward.tile.viewStartRow = 30; | ||
| 843 | const down = pointer(7, 2, 1); | ||
| 844 | const move = pointer(7, 5, 3); | ||
| 845 | const up = pointer(7, 5, 3); | ||
| 846 | forward.tile.canvas.dispatchEvent('pointerdown', down); | ||
| 847 | check('left drag start prevents native canvas selection', down.wasPrevented(), true); | ||
| 848 | check('left drag captures its pointer', forward.tile.canvas.hasPointerCapture(7), true); | ||
| 849 | check('left drag starts the 120ms scroll timer', h.intervals.at(-1)?.ms, 120); | ||
| 850 | forward.tile.canvas.dispatchEvent('pointermove', move); | ||
| 851 | check('drag move prevents native pointer behavior', move.wasPrevented(), true); | ||
| 852 | check( | ||
| 853 | 'forward drag retains absolute anchor and active geometry', | ||
| 854 | JSON.stringify(forward.tile.orderedSelection()), | ||
| 855 | JSON.stringify([{ col: 2, viewRow: 1, row: 31 }, { col: 5, viewRow: 3, row: 33 }]), | ||
| 856 | ); | ||
| 857 | forward.tile.canvas.dispatchEvent('pointerup', up); | ||
| 858 | check('drag release prevents native pointer behavior', up.wasPrevented(), true); | ||
| 859 | check('drag release gives up pointer capture', forward.tile.canvas.hasPointerCapture(7), false); | ||
| 860 | check('drag release clears the scroll timer', forward.tile.selectionScrollTimer, null); | ||
| 861 | 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); | ||
| 863 | check( | ||
| 864 | 'drag release passes forward daemon coordinates once', | ||
| 865 | JSON.stringify(forward.requestCalls), | ||
| 866 | JSON.stringify([[1, 31, 2, 33, 5]]), | ||
| 867 | ); | ||
| 868 | check( | ||
| 869 | 'drag release sends the exact 16-byte request', | ||
| 870 | Buffer.from(forward.sent[0]?.payload ?? []).toString('hex'), | ||
| 871 | '010000001f0000000200210000000500', | ||
| 872 | ); | ||
| 873 | |||
| 874 | const reverse = makeTile(); | ||
| 875 | reverse.tile.viewStartRow = 50; | ||
| 876 | reverse.tile.canvas.dispatchEvent('pointerdown', pointer(8, 7, 4)); | ||
| 877 | reverse.tile.canvas.dispatchEvent('pointermove', pointer(8, 1, 0)); | ||
| 878 | check( | ||
| 879 | 'reverse drag normalizes retained geometry for painting', | ||
| 880 | JSON.stringify(reverse.tile.orderedSelection()), | ||
| 881 | JSON.stringify([{ col: 1, viewRow: 0, row: 50 }, { col: 7, viewRow: 4, row: 54 }]), | ||
| 882 | ); | ||
| 883 | reverse.tile.canvas.dispatchEvent('pointerup', pointer(8, 1, 0)); | ||
| 884 | check( | ||
| 885 | 'reverse request preserves anchor and active direction for daemon extraction', | ||
| 886 | JSON.stringify(reverse.requestCalls), | ||
| 887 | JSON.stringify([[1, 54, 7, 50, 1]]), | ||
| 888 | ); | ||
| 889 | |||
| 890 | const clickOnly = makeTile(); | ||
| 891 | clickOnly.tile.selection = { anchor: { row: 1, col: 1 }, active: { row: 2, col: 2 }, requestId: 9, text: 'old' }; | ||
| 892 | clickOnly.tile.canvas.dispatchEvent('pointerdown', pointer(9, 4, 2)); | ||
| 893 | clickOnly.tile.canvas.dispatchEvent('pointerup', pointer(9, 4, 2)); | ||
| 894 | check('click without movement clears retained selection', clickOnly.tile.selection, null); | ||
| 895 | check('click without movement sends no request', clickOnly.sent.length, 0); | ||
| 896 | |||
| 897 | const cancelled = makeTile(); | ||
| 898 | cancelled.tile.canvas.dispatchEvent('pointerdown', pointer(10, 2, 1)); | ||
| 899 | const cancelTimer = cancelled.tile.selectionScrollTimer; | ||
| 900 | cancelled.tile.canvas.dispatchEvent('pointercancel', { pointerId: 10 }); | ||
| 901 | check('pointer cancel clears retained selection', cancelled.tile.selection, null); | ||
| 902 | check('pointer cancel clears drag state', cancelled.tile.drag, null); | ||
| 903 | check('pointer cancel stops the scroll timer', h.intervals[cancelTimer - 1]?.cleared, true); | ||
| 904 | check('pointer cancel sends no request', cancelled.sent.length, 0); | ||
| 905 | |||
| 906 | const refused = makeTile(); | ||
| 907 | refused.setRequestResult(-3); | ||
| 908 | refused.tile.canvas.dispatchEvent('pointerdown', pointer(11, 1, 1)); | ||
| 909 | refused.tile.canvas.dispatchEvent('pointermove', pointer(11, 2, 2)); | ||
| 910 | refused.tile.canvas.dispatchEvent('pointerup', pointer(11, 2, 2)); | ||
| 911 | check('failed selection request clears retained selection', refused.tile.selection, null); | ||
| 912 | check('failed selection request sends no frame', refused.sent.length, 0); | ||
| 913 | |||
| 914 | const scrolling = makeTile(); | ||
| 915 | scrolling.tile.scrollPages = 0; | ||
| 916 | scrolling.tile.onWheel({ deltaY: -1 }); | ||
| 917 | check('wheel request immediately updates displayed start row', scrolling.tile.viewStartRow, 25); | ||
| 918 | check('wheel sends scrollback fetch', scrolling.sent[0]?.type, 0x05); | ||
| 919 | let paintedScroll = 0; | ||
| 920 | scrolling.tile.paintScroll = () => { paintedScroll++; }; | ||
| 921 | const chunk = new Uint8Array(6 + 3); | ||
| 922 | new DataView(chunk.buffer).setUint32(0, 17, true); | ||
| 923 | new DataView(chunk.buffer).setUint16(4, 5, true); | ||
| 924 | chunk.set([1, 2, 3], 6); | ||
| 925 | const envelope = new Uint8Array(6 + chunk.length); | ||
| 926 | envelope[0] = 0; | ||
| 927 | envelope[1] = 0x85; | ||
| 928 | new DataView(envelope.buffer).setUint32(2, chunk.length, true); | ||
| 929 | envelope.set(chunk, 6); | ||
| 930 | scrolling.tile.onMessage(envelope); | ||
| 931 | check('scrollback echo is authoritative for displayed start row', scrolling.tile.viewStartRow, 17); | ||
| 932 | check('scrollback echo paints the history viewport', paintedScroll, 1); | ||
| 933 | |||
| 934 | const painted = makeTile(); | ||
| 935 | const order = []; | ||
| 936 | painted.tile.sizeCanvas = () => {}; | ||
| 937 | painted.tile.paintRow = (row) => { order.push(`row${row}`); }; | ||
| 938 | painted.tile.paintCursor = () => { order.push('cursor'); }; | ||
| 939 | painted.tile.paintSelection = () => { order.push('selection'); }; | ||
| 940 | painted.tile.core.mux_read_viewport = () => 2; | ||
| 941 | painted.tile.paintLive(); | ||
| 942 | check('live paint follows current history start', painted.tile.viewStartRow, 30); | ||
| 943 | check('live selection overlay paints after cells and cursor', order.join('|'), 'row0|row1|cursor|selection'); | ||
| 944 | order.length = 0; | ||
| 945 | painted.tile.viewStartRow = 12; | ||
| 946 | painted.tile.paintScroll(); | ||
| 947 | 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'); | ||
| 949 | |||
| 950 | const overlay = makeTile(); | ||
| 951 | const fills = []; | ||
| 952 | overlay.tile.ctx = { | ||
| 953 | fillStyle: '', | ||
| 954 | fillRect(x, y, width, height) { fills.push([x, y, width, height, this.fillStyle]); }, | ||
| 955 | }; | ||
| 956 | overlay.tile.viewStartRow = 100; | ||
| 957 | overlay.tile.selection = { | ||
| 958 | anchor: { row: 103, col: 4 }, active: { row: 101, col: 2 }, requestId: 1, text: null, | ||
| 959 | }; | ||
| 960 | overlay.tile.paintSelection(); | ||
| 961 | check( | ||
| 962 | 'retained overlay paints reverse multi-row geometry in history', | ||
| 963 | JSON.stringify(fills), | ||
| 964 | JSON.stringify([ | ||
| 965 | [16, 14, 64, 14, '#6ab0e055'], | ||
| 966 | [0, 28, 80, 14, '#6ab0e055'], | ||
| 967 | [0, 42, 40, 14, '#6ab0e055'], | ||
| 968 | ]), | ||
| 969 | ); | ||
| 970 | |||
| 971 | const replies = makeTile(); | ||
| 972 | const highId = 0xfedcba98; | ||
| 973 | replies.tile.selection = { | ||
| 974 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: highId, text: null, | ||
| 975 | }; | ||
| 976 | const authoritative = Buffer.from('daemon ☃', 'utf8'); | ||
| 977 | replies.setResult(highId, 0, authoritative); | ||
| 978 | replies.tile.onSelectionReply(); | ||
| 979 | new Uint8Array(replies.memory.buffer).fill(0, 96, 96 + authoritative.length); | ||
| 980 | check('matching high-bit reply binds with unsigned ID comparison', replies.tile.selection.text, 'daemon ☃'); | ||
| 981 | |||
| 982 | replies.tile.selection.text = 'keep'; | ||
| 983 | replies.setResult(123, 0, Buffer.from('stale')); | ||
| 984 | replies.tile.onSelectionReply(); | ||
| 985 | check('stale semantic reply cannot replace retained text', replies.tile.selection.text, 'keep'); | ||
| 986 | |||
| 987 | replies.setResult(highId, 2, []); | ||
| 988 | replies.tile.onSelectionReply(); | ||
| 989 | check('failed matching reply clears authoritative text', replies.tile.selection.text, null); | ||
| 990 | check('failed matching reply reports selection unavailable', `${replies.tile.copyButton.className}|${replies.tile.copyButton.textContent}`, 'copy-request on error|Selection unavailable'); | ||
| 991 | |||
| 992 | replies.tile.selection.text = 'previous'; | ||
| 993 | replies.setResult(highId, 0, [0xff]); | ||
| 994 | replies.tile.onSelectionReply(); | ||
| 995 | check('browser rejects invalid UTF-8 selection text defensively', replies.tile.selection.text, null); | ||
| 996 | |||
| 997 | const routed = makeTile(); | ||
| 998 | routed.tile.selection = { | ||
| 999 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 77, text: null, | ||
| 1000 | }; | ||
| 1001 | routed.setResult(77, 0, Buffer.from('routed')); | ||
| 1002 | const replyPayload = Uint8Array.from([77, 0, 0, 0, 0, ...Buffer.from('routed')]); | ||
| 1003 | const replyEnvelope = new Uint8Array(6 + replyPayload.length); | ||
| 1004 | replyEnvelope[0] = 0; | ||
| 1005 | replyEnvelope[1] = 0x90; | ||
| 1006 | new DataView(replyEnvelope.buffer).setUint32(2, replyPayload.length, true); | ||
| 1007 | replyEnvelope.set(replyPayload, 6); | ||
| 1008 | routed.tile.onMessage(replyEnvelope); | ||
| 1009 | check('selection-reply frame routes through semantic client core', routed.tile.selection.text, 'routed'); | ||
| 1010 | } | ||
| 1011 | |||
| 705 | // --- wire builders (layouts golden-pinned in protocol.zig) --- | 1012 | // --- wire builders (layouts golden-pinned in protocol.zig) --- |
| 706 | function snapshotPayload({ seq, history, cols, rows, epoch }, state) { | 1013 | function snapshotPayload({ seq, history, cols, rows, epoch }, state) { |
| 707 | const stateBytes = Buffer.from(state, 'utf8'); | 1014 | const stateBytes = Buffer.from(state, 'utf8'); |
| @@ -1129,6 +1436,7 @@ async function main() { | |||
| 1129 | const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); | 1436 | const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); |
| 1130 | const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8'); | 1437 | const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8'); |
| 1131 | await verifyClipboardShell(shell, html); | 1438 | await verifyClipboardShell(shell, html); |
| 1439 | verifySelectionShell(shell, html); | ||
| 1132 | 1440 | ||
| 1133 | // Mutation fixture for the source checks below. A correct-looking route | 1441 | // Mutation fixture for the source checks below. A correct-looking route |
| 1134 | // in either kind of comment must be invisible, while live literal | 1442 | // in either kind of comment must be invisible, while live literal |
| @@ -1162,11 +1470,12 @@ async function main() { | |||
| 1162 | check('source masker preserves literal syntactic boundaries', lexicalFixtureCompiles, true); | 1470 | check('source masker preserves literal syntactic boundaries', lexicalFixtureCompiles, true); |
| 1163 | 1471 | ||
| 1164 | const completeDecoy = [ | 1472 | const completeDecoy = [ |
| 1165 | 'const MSG = { term_modes: 0x8d, term_event: 0x8f };', | 1473 | 'const MSG = { term_modes: 0x8d, term_event: 0x8f, selection_reply: 0x90 };', |
| 1166 | 'const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3 };', | 1474 | 'const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3, selection: 4 };', |
| 1167 | 'case MSG.term_modes: case MSG.term_event: { if (!this.stage(payload)) return;', | 1475 | 'case MSG.term_modes: case MSG.term_event: case MSG.selection_reply: { if (!this.stage(payload)) return;', |
| 1168 | 'const action = this.core.mux_client_frame(type, payload.length);', | 1476 | 'const action = this.core.mux_client_frame(type, payload.length);', |
| 1169 | 'if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect(); return; }', | 1477 | 'if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect();', |
| 1478 | 'else if (action === CLIENT_ACTION.selection) this.onSelectionReply(); return; }', | ||
| 1170 | 'sendPaste(text) { if (this.core.mux_paste_begin() > 0) this.sendText(text);', | 1479 | 'sendPaste(text) { if (this.core.mux_paste_begin() > 0) this.sendText(text);', |
| 1171 | 'try { this.sendText(text); } finally { this.core.mux_paste_end(); } }', | 1480 | 'try { this.sendText(text); } finally { this.core.mux_paste_end(); } }', |
| 1172 | 'onClipboardEffect() {} this.core.mux_client_frame(type, payload.length);', | 1481 | 'onClipboardEffect() {} this.core.mux_client_frame(type, payload.length);', |
| @@ -1184,7 +1493,7 @@ async function main() { | |||
| 1184 | check('literal decoys add no CLIENT_ACTION declaration', (executableLiteralDecoys.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? []).length, 0); | 1493 | check('literal decoys add no CLIENT_ACTION declaration', (executableLiteralDecoys.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? []).length, 0); |
| 1185 | check( | 1494 | check( |
| 1186 | 'literal decoys add no semantic route body', | 1495 | 'literal decoys add no semantic route body', |
| 1187 | balancedBodiesAfter(executableLiteralDecoys, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{/g).length, | 1496 | balancedBodiesAfter(executableLiteralDecoys, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*case\s+MSG\.selection_reply\s*:\s*\{/g).length, |
| 1188 | 0, | 1497 | 0, |
| 1189 | ); | 1498 | ); |
| 1190 | check( | 1499 | check( |
| @@ -1233,7 +1542,8 @@ async function main() { | |||
| 1233 | 1542 | ||
| 1234 | const neighborFixture = executableJsTokens([ | 1543 | const neighborFixture = executableJsTokens([ |
| 1235 | 'case MSG.term_modes:', | 1544 | 'case MSG.term_modes:', |
| 1236 | 'case MSG.term_event: { if (ready) { route(); } }', | 1545 | 'case MSG.term_event:', |
| 1546 | 'case MSG.selection_reply: { if (ready) { route(); } }', | ||
| 1237 | 'case MSG.unrelated: { return; }', | 1547 | 'case MSG.unrelated: { return; }', |
| 1238 | 'case MSG.pty_mode: return;', | 1548 | 'case MSG.pty_mode: return;', |
| 1239 | 'sendPaste(text) { try { if (text) { this.sendText(text); } } finally { finish(); } }', | 1549 | 'sendPaste(text) { try { if (text) { this.sendText(text); } } finally { finish(); } }', |
| @@ -1242,7 +1552,7 @@ async function main() { | |||
| 1242 | ].join('\n')); | 1552 | ].join('\n')); |
| 1243 | check( | 1553 | check( |
| 1244 | 'semantic extraction ignores a later unrelated case', | 1554 | 'semantic extraction ignores a later unrelated case', |
| 1245 | balancedBodiesAfter(neighborFixture, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{/g).length, | 1555 | balancedBodiesAfter(neighborFixture, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*case\s+MSG\.selection_reply\s*:\s*\{/g).length, |
| 1246 | 1, | 1556 | 1, |
| 1247 | ); | 1557 | ); |
| 1248 | check( | 1558 | check( |
| @@ -1266,15 +1576,19 @@ async function main() { | |||
| 1266 | const msgDecl = msgObjects.length === 1 ? (msgObjects[0].body ?? '') : ''; | 1576 | const msgDecl = msgObjects.length === 1 ? (msgObjects[0].body ?? '') : ''; |
| 1267 | check('shell has one term_modes property', (msgDecl.match(/\bterm_modes\s*:/g) ?? []).length, 1); | 1577 | check('shell has one term_modes property', (msgDecl.match(/\bterm_modes\s*:/g) ?? []).length, 1); |
| 1268 | check('shell has one term_event property', (msgDecl.match(/\bterm_event\s*:/g) ?? []).length, 1); | 1578 | check('shell has one term_event property', (msgDecl.match(/\bterm_event\s*:/g) ?? []).length, 1); |
| 1579 | check('shell has one selection_req property', (msgDecl.match(/\bselection_req\s*:/g) ?? []).length, 1); | ||
| 1580 | check('shell has one selection_reply property', (msgDecl.match(/\bselection_reply\s*:/g) ?? []).length, 1); | ||
| 1269 | check('shell declares term_modes wire code', /\bterm_modes\s*:\s*0x8d\b/.test(msgDecl), true); | 1581 | check('shell declares term_modes wire code', /\bterm_modes\s*:\s*0x8d\b/.test(msgDecl), true); |
| 1270 | check('shell declares term_event wire code', /\bterm_event\s*:\s*0x8f\b/.test(msgDecl), true); | 1582 | check('shell declares term_event wire code', /\bterm_event\s*:\s*0x8f\b/.test(msgDecl), true); |
| 1583 | check('shell declares selection wire codes', /\bselection_req\s*:\s*0x0b\b/.test(msgDecl) | ||
| 1584 | && /\bselection_reply\s*:\s*0x90\b/.test(msgDecl), true); | ||
| 1271 | 1585 | ||
| 1272 | const actionBindings = executableShell.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? []; | 1586 | const actionBindings = executableShell.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? []; |
| 1273 | const actionObjects = balancedBodiesAfter(executableShell, /\bconst CLIENT_ACTION\s*=\s*\{/g); | 1587 | const actionObjects = balancedBodiesAfter(executableShell, /\bconst CLIENT_ACTION\s*=\s*\{/g); |
| 1274 | check('shell has exactly one live CLIENT_ACTION declaration', actionBindings.length, 1); | 1588 | check('shell has exactly one live CLIENT_ACTION declaration', actionBindings.length, 1); |
| 1275 | check('shell CLIENT_ACTION declaration has the expected object shape', actionObjects.length, 1); | 1589 | check('shell CLIENT_ACTION declaration has the expected object shape', actionObjects.length, 1); |
| 1276 | const actionDecl = actionObjects.length === 1 ? (actionObjects[0].body ?? '') : ''; | 1590 | const actionDecl = actionObjects.length === 1 ? (actionObjects[0].body ?? '') : ''; |
| 1277 | for (const name of ['ignored', 'terminalModes', 'clipboard', 'bell']) { | 1591 | for (const name of ['ignored', 'terminalModes', 'clipboard', 'bell', 'selection']) { |
| 1278 | check(`shell has one ${name} client action`, (actionDecl.match(new RegExp(`\\b${name}\\s*:`, 'g')) ?? []).length, 1); | 1592 | check(`shell has one ${name} client action`, (actionDecl.match(new RegExp(`\\b${name}\\s*:`, 'g')) ?? []).length, 1); |
| 1279 | } | 1593 | } |
| 1280 | check( | 1594 | check( |
| @@ -1282,16 +1596,18 @@ async function main() { | |||
| 1282 | /\bignored\s*:\s*0\b/.test(actionDecl) | 1596 | /\bignored\s*:\s*0\b/.test(actionDecl) |
| 1283 | && /\bterminalModes\s*:\s*1\b/.test(actionDecl) | 1597 | && /\bterminalModes\s*:\s*1\b/.test(actionDecl) |
| 1284 | && /\bclipboard\s*:\s*2\b/.test(actionDecl) | 1598 | && /\bclipboard\s*:\s*2\b/.test(actionDecl) |
| 1285 | && /\bbell\s*:\s*3\b/.test(actionDecl), | 1599 | && /\bbell\s*:\s*3\b/.test(actionDecl) |
| 1600 | && /\bselection\s*:\s*4\b/.test(actionDecl), | ||
| 1286 | true, | 1601 | true, |
| 1287 | ); | 1602 | ); |
| 1288 | 1603 | ||
| 1289 | const semanticRoutes = balancedBodiesAfter( | 1604 | const semanticRoutes = balancedBodiesAfter( |
| 1290 | executableShell, | 1605 | executableShell, |
| 1291 | /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{/g, | 1606 | /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*case\s+MSG\.selection_reply\s*:\s*\{/g, |
| 1292 | ); | 1607 | ); |
| 1293 | check('shell has one live term_modes case', (executableShell.match(/case\s+MSG\.term_modes\s*:/g) ?? []).length, 1); | 1608 | check('shell has one live term_modes case', (executableShell.match(/case\s+MSG\.term_modes\s*:/g) ?? []).length, 1); |
| 1294 | check('shell has one live term_event case', (executableShell.match(/case\s+MSG\.term_event\s*:/g) ?? []).length, 1); | 1609 | check('shell has one live term_event case', (executableShell.match(/case\s+MSG\.term_event\s*:/g) ?? []).length, 1); |
| 1610 | check('shell has one live selection_reply case', (executableShell.match(/case\s+MSG\.selection_reply\s*:/g) ?? []).length, 1); | ||
| 1295 | check('shell shares exactly one semantic frame route', semanticRoutes.length, 1); | 1611 | check('shell shares exactly one semantic frame route', semanticRoutes.length, 1); |
| 1296 | const semanticCases = semanticRoutes.length === 1 ? (semanticRoutes[0].body ?? '') : ''; | 1612 | const semanticCases = semanticRoutes.length === 1 ? (semanticRoutes[0].body ?? '') : ''; |
| 1297 | check('shell stages a semantic payload exactly once', (semanticCases.match(/this\.stage\(payload\)/g) ?? []).length, 1); | 1613 | check('shell stages a semantic payload exactly once', (semanticCases.match(/this\.stage\(payload\)/g) ?? []).length, 1); |
| @@ -1307,9 +1623,11 @@ async function main() { | |||
| 1307 | ); | 1623 | ); |
| 1308 | check('shell calls the semantic WASM entry exactly once', (semanticCases.match(/\.mux_client_frame\s*\(/g) ?? []).length, 1); | 1624 | check('shell calls the semantic WASM entry exactly once', (semanticCases.match(/\.mux_client_frame\s*\(/g) ?? []).length, 1); |
| 1309 | check( | 1625 | check( |
| 1310 | 'shell dispatches only the clipboard action', | 1626 | 'shell dispatches clipboard and selection semantic actions', |
| 1311 | /if\s*\(\s*action\s*===\s*CLIENT_ACTION\.clipboard\s*\)\s*this\.onClipboardEffect\(\)\s*;/.test(semanticCases) | 1627 | /if\s*\(\s*action\s*===\s*CLIENT_ACTION\.clipboard\s*\)\s*this\.onClipboardEffect\(\)\s*;/.test(semanticCases) |
| 1312 | && (semanticCases.match(/this\.onClipboardEffect\(\)/g) ?? []).length === 1, | 1628 | && /else\s+if\s*\(\s*action\s*===\s*CLIENT_ACTION\.selection\s*\)\s*this\.onSelectionReply\(\)\s*;/.test(semanticCases) |
| 1629 | && (semanticCases.match(/this\.onClipboardEffect\(\)/g) ?? []).length === 1 | ||
| 1630 | && (semanticCases.match(/this\.onSelectionReply\(\)/g) ?? []).length === 1, | ||
| 1313 | true, | 1631 | true, |
| 1314 | ); | 1632 | ); |
| 1315 | check('shell leaves semantic payload parsing to WASM', /DataView|TextDecoder|payload\s*\[/.test(semanticCases), false); | 1633 | check('shell leaves semantic payload parsing to WASM', /DataView|TextDecoder|payload\s*\[/.test(semanticCases), false); |