╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ script.js ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭ find file ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╮ ╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╮ │ │ │http│ ssh │ │ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╯ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╯
255 _navigate: function (ptr, len) { 256 // a real browser navigation to another top-level page (the wasm 257 // can't render it client-side without server data). 258 window.location.assign(readWasmString(ptr, len)); 259 }, 260 _scrollToFocus: function (id) { 261 grid.dataset.focusId = id; 262 // bring the focused widget into view within its own .scroll div. it's 263 // several spans (one per row) sharing this id, in top-to-bottom order, 264 // so the first gives its top/left edge and the last its bottom. 265 const els = focusElements(id); 266 if (els.length === 0) return; 267 const container = els[0].closest(".scroll"); 268 if (!container) return; 269 270 const c = container.getBoundingClientRect(); 271 const first = els[0].getBoundingClientRect(); 272 const last = els[els.length - 1].getBoundingClientRect(); 273 const top = first.top - c.top + container.scrollTop; 274 const bottom = last.bottom - c.top + container.scrollTop; 275 const left = first.left - c.left + container.scrollLeft; 276 let right = left; 277 for (const el of els) { 278 right = Math.max(right, el.getBoundingClientRect().right - c.left + container.scrollLeft); 279 } 280 281 // vertical is the priority axis. keep the viewport in place when it 282 // already overlaps a tall widget; otherwise reveal its nearest edge. 283 if (bottom - top > container.clientHeight) { 284 if (bottom <= container.scrollTop) { 285 container.scrollTop = bottom - container.clientHeight; 286 } else if (top >= container.scrollTop + container.clientHeight) { 287 container.scrollTop = top; 288 } 289 } else if (top < container.scrollTop) { 290 container.scrollTop = top; 291 } else if (bottom > container.scrollTop + container.clientHeight) { 292 container.scrollTop = bottom - container.clientHeight; 293 } 294 // reveal a widget that fits the view's width. a wider one keeps the 295 // horizontal position, which only arrow left/right changes. 296 if (right - left <= container.clientWidth) { 297 if (left < container.scrollLeft) { 298 container.scrollLeft = left; 299 } else if (right > container.scrollLeft + container.clientWidth) { 300 container.scrollLeft = right - container.clientWidth; 301 } 302 } 303 }, 304 }, 305 }; 306 307 function sendTextInputValue(focusId, value) { 308 const bytes = encoder.encode(value); 309 const ptr = wasmInstance.exports._alloc(bytes.length); 310 // an empty allocation's pointer lies outside the buffer, so a view onto it 311 // would throw and leave the wasm holding the old value 312 if (bytes.length > 0) { 313 new Uint8Array(wasmInstance.exports.memory.buffer, ptr, bytes.length).set(bytes); 314 } 315 wasmInstance.exports._setTextInputValue(focusId, ptr, bytes.length); 316 wasmInstance.exports._tick(minRows(), maxCols()); 317 } 318 319 // upload as soon as the file dialog returns, so attaching is one click. the 320 // file is the whole request body, so its name travels in a header. 321 document.addEventListener("change", (event) => { 322 const t = event.target; 323 if (!t || t.tagName !== "INPUT" || t.type !== "file" || !t.dataset.action) return; 324 const file = t.files[0]; 325 if (!file) return; 326 fetch(t.dataset.action, { 327 method: "POST", 328 headers: { "x-attachment-name": encodeURIComponent(file.name) }, 329 body: file, 330 }).then((res) => { 331 if (res.ok) window.location.reload(); 332 else res.text().then((message) => console.error("upload failed:", message)); 333 }); 334 }); 335 336 WebAssembly.instantiateStreaming(fetch("/haxy.wasm"), importObject).then(async (result) => { 337 wasmInstance = result.instance; 338 339 // wait for the @font-face to load before measuring cell width, otherwise 340 // we get the fallback font's metrics. 341 if (document.fonts && document.fonts.ready) { 342 await document.fonts.ready; 343 } 344 345 // the page is embedded as raw json in a script tag. 346 // encode to utf-8 bytes and hand it to the wasm. 347 const jsonBytes = encoder.encode(pageJson); 348 const ptr = wasmInstance.exports._alloc(jsonBytes.length); 349 new Uint8Array(wasmInstance.exports.memory.buffer, ptr, jsonBytes.length).set(jsonBytes); 350 wasmInstance.exports._init(ptr, jsonBytes.length, minRows(), maxCols()); 351 352 document.addEventListener("keydown", (event) => { 353 // let the browser handle modifier combos. the TUI only uses unmodified keys, 354 // so intercepting these would just break normal browser navigation. 355 if (event.altKey || event.ctrlKey || event.metaKey) return; 356 // when a form element (text input or submit button) owns focus, the 357 // browser handles typing and Tab natively (and Enter on the button 358 // submits). we only forward keys into the TUI when focus is elsewhere. 359 // exception: arrow up/down on an input unfocuses it and forwards 360 // the event so the TUI can move focus between widgets. 361 if (document.activeElement) { 362 const tag = document.activeElement.tagName; 363 const isArrow = event.key === "ArrowUp" || event.key === "ArrowDown" || 364 event.key === "ArrowLeft" || event.key === "ArrowRight"; 365 // a file picker is an input but behaves like a button: every key 366 // but an arrow is native (enter opens its dialog), and an arrow 367 // navigates away from it 368 if (tag === "INPUT" && document.activeElement.type === "file") { 369 if (!isArrow) return; 370 document.activeElement.blur(); 371 } else if (tag === "INPUT" && document.activeElement.readOnly && 372 ["Enter", "ArrowLeft", "ArrowRight"].includes(event.key)) { 373 event.preventDefault(); 374 if (event.key === "Enter") { 375 document.activeElement.select(); 376 return; 377 } 378 wasmInstance.exports._onKeyDown(event.keyCode); 379 wasmInstance.exports._tick(minRows(), maxCols()); 380 return; 381 } else if (tag === "INPUT" && event.key === "Enter") { 382 // enter in a form's text input never submits it; only the 383 // submit button does. a form with no button submits on enter. 384 // an input outside a form has no submit button, so the TUI 385 // handles its enter instead. 386 const form = document.activeElement.form; 387 if (form && ![...form.elements].some((element) => element.tagName === "BUTTON")) return; 388 event.preventDefault(); 389 if (!document.activeElement.form) { 390 wasmInstance.exports._onKeyDown(event.keyCode); 391 wasmInstance.exports._tick(minRows(), maxCols()); 392 } 393 return; 394 } else if (tag === "INPUT" && !document.activeElement.form && !document.activeElement.readOnly && 395 (event.key === "Tab" && !event.shiftKey || 396 event.key === "ArrowRight" && caretAtEnd(document.activeElement))) { 397 // an input outside a form (a search box) reaches the widgets 398 // beside it: Tab, and ArrowRight once the caret is at the end. 399 // blurred first, so the next key isn't the input's; the tick 400 // refocuses it if the TUI focus stayed. 401 event.preventDefault(); 402 document.activeElement.blur(); 403 wasmInstance.exports._onKeyDown(event.keyCode); 404 wasmInstance.exports._tick(minRows(), maxCols()); 405 return; 406 } else if (tag === "INPUT" && (event.key === "ArrowUp" || event.key === "ArrowDown")) { 407 // up/down moves between widgets; left/right stay in the input 408 document.activeElement.blur(); 409 } else if (tag === "BUTTON" && isArrow) { 410 // arrows navigate away from a focused submit button 411 document.activeElement.blur(); 412 } else if (tag === "INPUT" || tag === "BUTTON" || tag === "TEXTAREA") { 413 // Enter (submit / newline), typing, Tab, and a textarea's 414 // arrows — handled natively by the browser 415 return; 416 } 417 } 418 if ((event.key === "ArrowUp" || event.key === "ArrowDown") && scrollFocusedWidget(event.key === "ArrowDown")) { 419 event.preventDefault(); 420 return; 421 } 422 if ((event.key === "PageUp" || event.key === "PageDown") && pageFocusedScroll(event.key === "PageDown")) { 423 event.preventDefault(); 424 return; 425 } 426 if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "PageUp", "PageDown", "Home", "End"].includes(event.key)) { 427 event.preventDefault(); 428 } 429 wasmInstance.exports._onKeyDown(event.keyCode); 430 wasmInstance.exports._tick(minRows(), maxCols()); 431 }); 432 433 // delegated listeners survive cell-layer replacement. the controls 434 // themselves are retained inside #grid. 435 document.addEventListener("input", (event) => { 436 const t = event.target; 437 if (!t || (t.tagName !== "INPUT" && t.tagName !== "TEXTAREA") || !t.dataset.focusId) return; 438 sendTextInputValue(Number(t.dataset.focusId), t.value); 439 }); 440 441 document.addEventListener("focusin", (event) => { 442 const t = event.target; 443 if (!t || !t.dataset || !t.dataset.focusId) return; 444 // sync the wasm focus to wherever the browser moved focus 445 wasmInstance.exports._setFocus(Number(t.dataset.focusId)); 446 // keyed controls survive a redraw before click. cell links live in the 447 // replaceable backdrop, so their click handler redraws after activation. 448 if (nodeKey(t)?.startsWith("control:")) wasmInstance.exports._tick(minRows(), maxCols()); 449 }); 450 451 grid.addEventListener("click", (event) => { 452 if (event.target.matches("input[readonly]")) { 453 wasmInstance.exports._tick(minRows(), maxCols()); 454 return; 455 } 456 // a link inside a cell's text opens in a new tab on its own 457 if (event.target.closest("a.cell-link")) return; 458 const span = event.target.closest(".clickable"); 459 if (!span) return; 460 // leave modified / non-left clicks (open-in-new-tab, etc.) to the browser 461 if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; 462 // wasm routes the click: an in-page link updates focus and re-renders, 463 // a cross-page one calls _navigate. suppress the <a>'s own navigation so 464 // an in-page link doesn't also trigger a redundant full page load. 465 event.preventDefault(); 466 const focusId = Number(span.dataset.focusId); 467 wasmInstance.exports._onMouseClick(focusId); 468 wasmInstance.exports._tick(minRows(), maxCols()); 469 }); 470 471 let resizeTimer = null; 472 window.addEventListener("resize", () => { 473 clearTimeout(resizeTimer); 474 resizeTimer = setTimeout(() => { 475 wasmInstance.exports._tick(minRows(), maxCols()); 476 }, 100); 477 }); 478 }); 479