╭ script.js ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const grid = document.getElementById("grid"); 2 const pageJsonBase64 = document.getElementById("page").textContent; 3 4 let wasmInstance; 5 const decoder = new TextDecoder(); 6 const encoder = new TextEncoder(); 7 let currentHtml = ""; 8 9 const MIN_COLS = 30; 10 11 // measured once after the font is loaded. recomputed lazily if either reads 12 // back as zero (e.g. measured before the font was actually ready). 13 let cellWidth = null; 14 let cellHeight = null; 15 16 function measureCell() { 17 const probe = document.createElement("span"); 18 probe.style.position = "absolute"; 19 probe.style.visibility = "hidden"; 20 probe.style.whiteSpace = "pre"; 21 probe.textContent = "X".repeat(100); 22 grid.appendChild(probe); 23 const rect = probe.getBoundingClientRect(); 24 probe.remove(); 25 cellWidth = rect.width / 100; 26 cellHeight = rect.height; 27 } 28 29 function maxCols() { 30 if (!cellWidth) measureCell(); 31 return Math.max(MIN_COLS, Math.floor(document.body.clientWidth / cellWidth)); 32 } 33 34 // minimum row count for the wasm build — the TUI fills at least the 35 // viewport height, longer content extends past it and the browser scrolls. 36 function minRows() { 37 if (!cellHeight) measureCell(); 38 return Math.max(1, Math.floor(document.documentElement.clientHeight / cellHeight)); 39 } 40 41 function readWasmString(ptr, len) { 42 const memory = wasmInstance.exports.memory; 43 return decoder.decode(new Uint8Array(memory.buffer, ptr, len)); 44 } 45 46 const importObject = { 47 env: { 48 _consoleLog: function (ptr, len) { 49 console.log(readWasmString(ptr, len)); 50 }, 51 _setHtml: function (ptr, len) { 52 const html = readWasmString(ptr, len); 53 if (html === currentHtml) return; 54 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 } 73 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 } 91 }, 92 }, 93 }; 94 95 function sendTextInputValue(focusId, value) { 96 const bytes = encoder.encode(value); 97 const ptr = wasmInstance.exports._alloc(bytes.length); 98 new Uint8Array(wasmInstance.exports.memory.buffer, ptr, bytes.length).set(bytes); 99 wasmInstance.exports._setTextInputValue(focusId, ptr, bytes.length); 100 wasmInstance.exports._tick(minRows(), maxCols()); 101 } 102 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 116 WebAssembly.instantiateStreaming(fetch("haxy.wasm"), importObject).then(async (result) => { 117 wasmInstance = result.instance; 118 119 // wait for the @font-face to load before measuring cell width, otherwise 120 // we get the fallback font's metrics. 121 if (document.fonts && document.fonts.ready) { 122 await document.fonts.ready; 123 } 124 125 // the page is embedded as base64 so it can sit inside the host html 126 // without worrying about characters that would terminate the script tag. 127 // decode here and hand the raw json bytes to the wasm. 128 const jsonBytes = Uint8Array.from(atob(pageJsonBase64), (c) => c.charCodeAt(0)); 129 const ptr = wasmInstance.exports._alloc(jsonBytes.length); 130 new Uint8Array(wasmInstance.exports.memory.buffer, ptr, jsonBytes.length).set(jsonBytes); 131 wasmInstance.exports._start(ptr, jsonBytes.length, minRows(), maxCols()); 132 133 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; 155 } 156 if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "PageUp", "PageDown", "Home", "End"].includes(event.key)) { 157 event.preventDefault(); 158 } 159 wasmInstance.exports._onKeyDown(event.keyCode); 160 wasmInstance.exports._tick(minRows(), maxCols()); 161 }); 162 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) => { 167 const t = event.target; 168 if (!t || t.tagName !== "INPUT" || !t.dataset.focusId) return; 169 sendTextInputValue(Number(t.dataset.focusId), t.value); 170 }); 171 172 grid.addEventListener("focusin", (event) => { 173 const t = event.target; 174 if (!t || !t.dataset || !t.dataset.focusId) return; 175 wasmInstance.exports._onMouseClick(Number(t.dataset.focusId)); 176 wasmInstance.exports._tick(minRows(), maxCols()); 177 }); 178 179 // submit handled on mousedown rather than click because submit-button 180 // spans are tabbable: a mouse click on one moves focus to it, which 181 // fires focusin, which re-renders via innerHTML, which detaches the 182 // very span the click event was about to target. mousedown fires 183 // before the focus change, so the target is guaranteed to be live. 184 grid.addEventListener("mousedown", (event) => { 185 const span = event.target.closest(".clickable"); 186 if (!span || span.dataset.action !== "submit") return; 187 event.preventDefault(); 188 submitForm(span.dataset.url); 189 }); 190 191 grid.addEventListener("click", (event) => { 192 const span = event.target.closest(".clickable"); 193 if (!span) return; 194 // submit is handled in mousedown above 195 if (span.dataset.action === "submit") { 196 event.preventDefault(); 197 return; 198 } 199 const focusId = Number(span.dataset.focusId); 200 wasmInstance.exports._onMouseClick(focusId); 201 // synthesize Enter so the focused widget's "activate" handler fires 202 // (e.g. a button click runs its submit handler). widgets that don't 203 // care about Enter just ignore it. 204 wasmInstance.exports._onKeyDown(13); 205 wasmInstance.exports._tick(minRows(), maxCols()); 206 }); 207 208 let resizeTimer = null; 209 window.addEventListener("resize", () => { 210 clearTimeout(resizeTimer); 211 resizeTimer = setTimeout(() => { 212 wasmInstance.exports._tick(minRows(), maxCols()); 213 }, 100); 214 }); 215 }); 216