╭ changed ───────────╮ │src/embed/index.html│ ╰────────────────────╯ ╭────────────────────────────────────────────────────────╮ │15 src: url('term.ttf') format('truetype');│ │16 } │ │17 │ │18 - #grid { │ │18 + .page { │ │19 font-family: '3270', monospace; │ │20 - white-space: pre; │ │20 font-size: 22px; │ │21 line-height: 22px; │ │22 position: relative; │ ╰────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────╮ │23 } │ │24 │ │26 - #grid input { │ │25 + #grid { │ │26 + white-space: pre; │ │27 + } │ │28 + │ │29 + /* overlay elements share the grid's coordinate system since│ │30 + their parent (.page) is the positioning ancestor and the │ │31 + grid sits at its top-left. */ │ │32 + .page input, │ │33 + .page button { │ │34 position: absolute; │ │35 font: inherit; │ │36 + padding: 0; │ │37 + margin: 0; │ │38 + outline: none; │ │39 + box-sizing: border-box; │ │40 + } │ │41 + │ │42 + .page input { │ │43 color: inherit; │ │44 background: rgba(0, 50, 70, 1.0); │ │45 border: none; │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────╮ │32 - outline: none; │ │33 - padding: 0; │ │34 - margin: 0; │ │35 - box-sizing: border-box; │ │46 + } │ │47 + │ │48 + /* the TUI cells under the button render the border + label;│ │49 + the overlay button stays transparent so they show through│ │50 + and just owns the click + form-submit behavior. */ │ │51 + .page button { │ │52 + background: transparent; │ │53 + color: transparent; │ │54 + border: none; │ │55 + cursor: pointer; │ │56 } │ │57 │ │58 #grid a { │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │65 </style> │ │66 </head> │ │67 <body> │ │48 - <div id="grid">{{{ HAXY_HTML }}}</div> │ │49 - <script id="page" type="text/plain">{{{ HAXY_JSON }}}</script> │ │68 + <div class="page"> │ │69 + <div id="grid">{{{ HAXY_HTML }}}</div> │ │70 + <div id="overlay">{{{ HAXY_OVERLAY }}}</div> │ │71 + </div> │ │72 + <script id="page-data" type="text/plain">{{{ HAXY_JSON }}}</script>│ │73 <script src="script.js"></script> │ │74 </body> │ │75 </html> │ ╰─────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────╮ │src/embed/script.js│ ╰───────────────────╯ ╭───────────────────────────────────────────────────────────────────────────╮ │1 const grid = document.getElementById("grid"); │ │2 -const pageJsonBase64 = document.getElementById("page").textContent; │ │2 +const overlay = document.getElementById("overlay"); │ │3 +const pageJsonBase64 = document.getElementById("page-data").textContent;│ │4 │ │5 let wasmInstance; │ │6 const decoder = new TextDecoder(); │ ╰───────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │ 7 const encoder = new TextEncoder(); │ │ 8 let currentHtml = ""; │ │ 9 +// seeded from the server-rendered overlay so the first wasm tick can no-op│ │10 +// when its layout happens to match the server's. otherwise the first tick │ │11 +// always rebuilds and any pre-load focus state is lost. │ │12 +let currentOverlay = overlay.innerHTML; │ │13 │ │14 const MIN_COLS = 30; │ │15 │ ╰───────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │57 const html = readWasmString(ptr, len); │ │58 if (html === currentHtml) return; │ │59 currentHtml = html; │ │55 - // capture which focusable element had focus + (for inputs) its │ │56 - // cursor position so we can restore it after innerHTML wipes │ │57 - // and re-creates the elements. covers both overlay inputs AND │ │58 - // tabbable submit-button spans; otherwise focus on the latter │ │59 - // would be lost on every re-render, and Enter would fall │ │60 - // through to the wasm key dispatch instead of being intercepted │ │61 - // as a form submission. │ │62 - const active = document.activeElement; │ │63 - let savedFocusId = null; │ │64 - let savedStart = null; │ │65 - let savedEnd = null; │ │66 - if (active && active.dataset && active.dataset.focusId) { │ │67 - savedFocusId = active.dataset.focusId; │ │68 - if (active.tagName === "INPUT") { │ │69 - savedStart = active.selectionStart; │ │70 - savedEnd = active.selectionEnd; │ │71 - } │ │72 - } │ │60 + // the grid only holds non-focusable TUI cell spans now; the │ │61 + // form (inputs + submit button) lives in #overlay and is │ │62 + // never touched by this wipe, so we don't need to save and │ │63 + // restore focus across the swap. │ │64 grid.innerHTML = html; │ │74 - // prefer whatever wasm marked as focused — that way arrow-key │ │75 - // navigation in the TUI also moves DOM focus to the matching │ │76 - // <input>. fall back to the previously focused element so │ │77 - // typing doesn't lose its caret position on re-renders that │ │78 - // don't change focus. │ │79 - const focused = grid.querySelector('[data-focused="true"]'); │ │80 - const target = focused || (savedFocusId !== null │ │81 - ? grid.querySelector(`[data-focus-id="${savedFocusId}"]`) │ │82 - : null); │ │83 - if (target && typeof target.focus === "function") { │ │84 - target.focus(); │ │85 - // only restore selection when we land on the same element │ │86 - // we captured from; otherwise the offsets are meaningless. │ │87 - if (savedStart !== null && target.dataset && target.dataset.focusId === savedFocusId && target.setSelectionRange) {│ │88 - try { target.setSelectionRange(savedStart, savedEnd); } catch (_) {} │ │89 - } │ │90 - } │ │65 + }, │ │66 + _setOverlay: function (ptr, len) { │ │67 + const html = readWasmString(ptr, len); │ │68 + // diff against the previous overlay HTML; an unchanged overlay │ │69 + // means no layout/structure has shifted (typing alone doesn't │ │70 + // produce a diff because we deliberately omit the `value` │ │71 + // attribute on inputs — the browser tracks that). skipping the │ │72 + // innerHTML assignment keeps the live <form> alive across the │ │73 + // mousedown→focusin→tick→click sequence, which is what lets │ │74 + // the very first click on the submit button actually submit. │ │75 + if (html === currentOverlay) return; │ │76 + currentOverlay = html; │ │77 + overlay.innerHTML = html; │ │78 }, │ │79 }, │ │80 }; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 87 wasmInstance.exports._tick(minRows(), maxCols()); │ │ 88 } │ │ 89 │ │103 -// POST the current input values to the server form route. on response (any │ │104 -// status) we navigate to / so the server can render the page with whatever │ │105 -// session cookie it just set. │ │106 -function submitForm(url) { │ │107 - const params = new URLSearchParams(); │ │108 - const inputs = grid.querySelectorAll("input"); │ │109 - for (const input of inputs) { │ │110 - if (input.name) params.append(input.name, input.value); │ │111 - } │ │112 - fetch(url, { method: "POST", body: params, redirect: "manual", credentials: "same-origin" })│ │113 - .finally(() => { window.location.href = "/"; }); │ │114 -} │ │115 - │ │ 90 WebAssembly.instantiateStreaming(fetch("haxy.wasm"), importObject).then(async (result) => { │ │ 91 wasmInstance = result.instance; │ │ 92 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │105 wasmInstance.exports._start(ptr, jsonBytes.length, minRows(), maxCols()); │ │106 │ │107 document.addEventListener("keydown", (event) => { │ │134 - // when an overlay input owns focus, the browser handles typing & │ │135 - // cursor movement natively; wasm gets the new value via input events │ │136 - // instead of per-key dispatch. don't forward, and don't intercept │ │137 - // arrow / home / end which the input needs for cursor movement. │ │138 - // exception: Enter triggers form submission when one is on the page. │ │139 - if (document.activeElement && document.activeElement.tagName === "INPUT") { │ │140 - if (event.key === "Enter") { │ │141 - event.preventDefault(); │ │142 - const submitSpan = grid.querySelector('[data-action="submit"]'); │ │143 - if (submitSpan) { │ │144 - submitForm(submitSpan.dataset.url); │ │145 - } │ │146 - } │ │147 - return; │ │148 - } │ │149 - // a focused submit-button span (Tab can land here now that it has a │ │150 - // tabindex) should submit on Enter the same as a click does. │ │151 - if (event.key === "Enter" && document.activeElement && document.activeElement.dataset && document.activeElement.dataset.action === "submit") {│ │152 - event.preventDefault(); │ │153 - submitForm(document.activeElement.dataset.url); │ │154 - return; │ │108 + // when a form element (text input or submit button) owns focus, │ │109 + // the browser handles typing, Enter-to-submit, and Tab natively. │ │110 + // we only forward keys into the TUI when focus is elsewhere. │ │111 + if (document.activeElement) { │ │112 + const tag = document.activeElement.tagName; │ │113 + if (tag === "INPUT" || tag === "BUTTON") return; │ │114 } │ │115 if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "PageUp", "PageDown", "Home", "End"].includes(event.key)) { │ │116 event.preventDefault(); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │119 wasmInstance.exports._tick(minRows(), maxCols()); │ │120 }); │ │121 │ │163 - // event delegation: each render rebuilds the input elements (innerHTML │ │164 - // wipe), so we can't attach listeners per-input. delegating on grid │ │165 - // catches inputs created at any point. │ │166 - grid.addEventListener("input", (event) => { │ │122 + // listeners on document (not #grid) since the form lives outside #grid;│ │123 + // input/focus events from form elements still bubble up to document. │ │124 + document.addEventListener("input", (event) => { │ │125 const t = event.target; │ │126 if (!t || t.tagName !== "INPUT" || !t.dataset.focusId) return; │ │127 sendTextInputValue(Number(t.dataset.focusId), t.value); │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯