// The browser half of improvise-cr3, kept as thin as it can be made, now // talking to either of two peers (improvise-djm). // // The boundary is JSON strings in and out, so this file holds no protocol // knowledge at all: it never inspects a Command, never builds a CellId, never // decides whether a cell is editable or whether a typed string is a number. // Five calls are the whole harness — // // client.set_viewport(rows, cols) -> the handshake frame, and every resize; // also records the window so keys resolve here // client.on_frame(json) -> fold one frame, in arrival order // client.render_model() -> everything to paint, already decided // client.typed_cell(r, c, s) -> the frame for "the user typed s here" // client.on_key_event(k,c,a,s,m) -> the frame for a keypress, folded locally // first where this client may answer it // // — and the two rules this file must not break are ordering and framing: // frames are folded one at a time in the order they arrive (a cell update // that overtakes its labels is dropped, not replayed), and the *first* frame // out must be the viewport, because the server builds the snapshot at it. // // # Two transports, one client // // The wasm bundle is the SAME artifact either way (improvise-xks6: one // envelope pair, two deployments). Only where the strings come from differs, // so the difference is confined to `openTransport` and nothing below it knows // which peer it has: // // default an in-page Web Worker hosting the whole App over OPFS // (improvise-9x6). No server process; this is what makes a // GitHub Pages copy of this directory a working application. // ?ws=host:port // a websocket to `improvise-ws-server`. That process is how a // document opened in the TUI is shared, so the path is kept; the // presence of the parameter is the whole selection mechanism, // because a second knob naming the same fact is a second thing to // get wrong. The scheme defaults to `wss://` — see `socketUrl`. // // Neither is reachable silently. Every transport writes what it is doing to // `#status` — "starting", "connected", "cannot reach …", "disconnected" — so // a peer that never arrives reads differently from one that went away. import init, { Client } from "./pkg/improvise_wasm_client.js"; const params = new URLSearchParams(location.search); // present => socket, absent => in-page worker. The value is a host:port, and // the scheme is NOT the caller's to get wrong: see `socketUrl`. const WS_PEER = params.get("ws"); /** * The websocket URL for `?ws=`, defaulting to TLS. * * `wss://` is the default and a bare `host:port` gets it, because the address * that works from where this page is actually served is the one it should be * easiest to write. This page ships to GitHub Pages, which is https, and an * https origin refuses a `ws://` address as mixed content — so a `ws://` * default would hand the browser a URL that cannot work and report it as a * connection failure, which reads like "the server is not running". * * An explicit `ws://` is honoured only where it can function: a page served * over plain http, which is the local `python3 -m http.server` case that pairs * with `improvise-ws-server` (which speaks plain `ws://` only — it has no TLS, * so reaching it from https means terminating TLS in front of it). From an * https page an explicit `ws://` is upgraded rather than passed through, since * passing it through can only produce a failure the user cannot act on. */ function socketUrl(peer) { const secure = location.protocol === "https:"; if (/^wss:\/\//i.test(peer)) return peer; if (/^ws:\/\//i.test(peer)) return secure ? peer.replace(/^ws:/i, "wss:") : peer; return `${secure ? "wss" : "ws"}://${peer}`; } const WS_URL = WS_PEER ? socketUrl(WS_PEER) : null; const DOC = params.get("doc") ?? "demo.improv"; const SEED = params.get("seed") ?? "demo.improv"; // Rough character metrics, used only to turn the window size into an extent. // Being wrong here costs a row of over- or under-fetch, nothing more. const ROW_PX = 26; const COL_PX = 110; const el = { grid: document.getElementById("grid"), categories: document.getElementById("categories"), mode: document.getElementById("mode"), hint: document.getElementById("hint"), status: document.getElementById("status"), viewName: document.getElementById("view-name"), }; await init(); const client = new Client(); // `#status` carries two independent facts — what the transport is doing, and // what the document last said — so it composes them rather than letting a // repaint quietly erase a connection error. let shellStatus = ""; let modelStatus = ""; function paintStatus() { el.status.textContent = [shellStatus, modelStatus].filter(Boolean).join(" · "); } function setShellStatus(text) { shellStatus = text; paintStatus(); } /** The extent this window can show, in whole cells. */ function extent() { const box = el.grid.getBoundingClientRect(); return { rows: Math.max(1, Math.floor(box.height / ROW_PX) - 1), cols: Math.max(1, Math.floor(box.width / COL_PX) - 1), }; } function sendViewport() { const { rows, cols } = extent(); // `client.set_viewport`, NOT the associated `Client.viewport`: the same // frame, but it also RECORDS the window. A client learns its window only // from what it announced -- `row_labels.len()` is the window clipped to the // grid, so the two differ exactly at the bottom edge, which is where the // scroll arithmetic matters. Without this the client cannot clamp, so every // cursor key goes upstream into `Host::handle`'s empty vec and the arrow // keys silently do nothing (improvise-bpq2). transport.send(client.set_viewport(rows, cols)); } // A peer that never answers the handshake. The socket has an `error` event // for this; the worker has none that fires, because worker.js catches its own // `start()` rejection — so a browser with no OPFS, or a worker bundle that // failed to load, would leave the page saying "in-page worker" over an empty // grid forever. The watchdog is deliberately about the *first frame* rather // than about either peer's own failure modes: it is the one condition that // means the same thing in both deployments. const HANDSHAKE_TIMEOUT_MS = 8000; let watchdog = null; function armWatchdog(message) { clearTimeout(watchdog); watchdog = setTimeout(() => setShellStatus(message), HANDSHAKE_TIMEOUT_MS); } // A peer that failed *loudly* has already said something more precise than // the watchdog's guess, so the watchdog must be disarmed and not merely // pre-empted: it fires later and would overwrite the better message with // "did not answer" (PR #13 review). The watchdog is for silence only. function setShellFailure(text) { clearTimeout(watchdog); watchdog = null; setShellStatus(text); } /** One frame, one fold, in arrival order. Never batch or reorder these. */ function onFrame(text) { clearTimeout(watchdog); try { client.on_frame(text); } catch (err) { // `on_frame` returns a Result, which wasm-bindgen throws. Thrown in a bare // listener it reached the devtools console and nothing else, and the grid // simply stopped updating with nothing on screen saying why (improvise-hwpi). setShellStatus("bad frame — the grid has stopped updating (see console)"); console.error("improvise: frame rejected", text, err); return; } repaint(); } // ── transports ─────────────────────────────────────────────────────────── /** The in-page worker: the standalone deployment, no server process. */ function workerTransport() { const query = new URLSearchParams({ doc: DOC, seed: SEED }); // `type: "module"` is not optional: worker.js is an ES module (it `import`s // the worker bundle), and a classic worker would fail to parse it. const worker = new Worker(`./worker.js?${query}`, { type: "module" }); worker.addEventListener("message", (event) => onFrame(event.data)); worker.addEventListener("messageerror", () => setShellFailure("the worker sent a frame this page could not read"), ); worker.addEventListener("error", (event) => { setShellFailure(`worker failed to start: ${event.message ?? "see console"}`); console.error("improvise: worker error", event); }); setShellStatus(`in-page worker · ${DOC}`); // Frames posted before the worker has finished `start()` are held by // worker.js and replayed in order, so the handshake can go out immediately. return { send: (text) => worker.postMessage(text), ready: Promise.resolve() }; } /** A websocket to `improvise-ws-server`: how a TUI-hosted document is shared. */ function socketTransport() { const socket = new WebSocket(WS_URL); const ready = new Promise((resolve) => { socket.addEventListener("open", () => { setShellStatus(`connected · ${WS_URL}`); resolve(); }); }); socket.addEventListener("message", (event) => onFrame(event.data)); // Without this, a connection that never opens left #status blank and // "server not running" read exactly like "still connecting" (improvise-hwpi). socket.addEventListener("error", () => setShellFailure(`cannot reach ${WS_URL} — is improvise-ws-server running?`), ); socket.addEventListener("close", () => setShellStatus("disconnected")); setShellStatus(`connecting to ${WS_URL}…`); return { send: (text) => { if (socket.readyState === WebSocket.OPEN) socket.send(text); }, ready, }; } const transport = WS_URL ? socketTransport() : workerTransport(); // ── painting ───────────────────────────────────────────────────────────── let painted = -1; let editing = null; // {row, col} while an input is open, so a repaint cannot steal it function repaint() { if (editing) return; let model; try { model = JSON.parse(client.render_model()); } catch (err) { setShellStatus("cannot render the current state (see console)"); console.error("improvise: render failed", err); return; } if (model.revision === painted) return; painted = model.revision; el.mode.textContent = model.mode; // The mode's own hint, off `AppMode::hint_text()`. The page spells no // keys itself; see `RenderModel::hint`. el.hint.textContent = model.hint; modelStatus = model.status; paintStatus(); el.viewName.textContent = model.view_name; el.grid.replaceChildren(gridTable(model)); el.categories.replaceChildren(categoryList(model)); } function gridTable(model) { const table = document.createElement("table"); const head = table.createTHead().insertRow(); head.appendChild(document.createElement("th")); for (const label of model.col_labels) { const th = document.createElement("th"); th.textContent = label; head.appendChild(th); } const body = table.createTBody(); model.rows.forEach((row, r) => { const tr = body.insertRow(); const header = document.createElement("th"); header.scope = "row"; header.textContent = row.label; tr.appendChild(header); row.cells.forEach((cell, c) => { const td = tr.insertCell(); // A keyboard edit is open on the selected cell when `model.editing` is // a string; it holds what has been typed so far. Both facts are // decided in Rust (`RenderModel::editing`) — the page does not know // which modes are editing modes or which buffer they type into. // Without this the mode indicator would say INSERT while every // keystroke vanished into a buffer nobody drew. const typing = cell.selected && typeof model.editing === "string"; td.textContent = typing ? model.editing + "\u258f" : cell.text; td.classList.toggle("numeric", cell.numeric && !typing); td.classList.toggle("error", cell.error); td.classList.toggle("selected", cell.selected); td.classList.toggle("typing", typing); // `editable` is the server's answer relayed through Rust: a stored // cell. An aggregate is a drill target and drill is not on the wire // yet, so it is simply not offered here. td.classList.toggle("editable", cell.editable); if (cell.editable) td.addEventListener("click", () => edit(td, r, c, cell.edit_text)); }); }); return table; } /** Open an inline editor. Committing sends a frame; the value that comes * back is the server's, never the string typed here. */ function edit(td, row, col, seed) { if (editing) return; editing = { row, col }; // `seed` is `RenderCell::edit_text` -- the STORED value, not the drawn // one. `td.textContent` has been through the number format, so a stored // 1200 reads "1,200" and committing that stores the string "1,200". const original = seed; const input = document.createElement("input"); input.value = original; td.replaceChildren(input); input.focus(); input.select(); // Enter removes the focused , which fires `blur`, which calls // `close` again. Harmless before, but only by accident (improvise-hwpi). let closed = false; const close = (commit) => { if (closed) return; closed = true; editing = null; if (commit) { const frame = client.typed_cell(row, col, input.value); // null only if the grid moved under the editor; the repaint that // follows restores whatever the server now says is there. if (frame) transport.send(frame); } td.replaceChildren(original); painted = -1; repaint(); }; input.addEventListener("keydown", (e) => { if (e.key === "Enter") close(true); else if (e.key === "Escape") close(false); // The editor owns the keyboard while it is open; the page-level handler // below must not also see these. e.stopPropagation(); }); input.addEventListener("blur", () => close(false)); } function categoryList(model) { const list = document.createElement("ul"); for (const row of model.categories) { const li = document.createElement("li"); li.className = row.is_category ? "category" : "item"; li.classList.toggle("selected", row.selected); li.textContent = row.is_category ? `${row.expanded ? "▾" : "▸"} ${row.label} (${row.item_count}) ${row.axis}` : row.label; list.appendChild(li); } return list; } // ── the keyboard ───────────────────────────────────────────────────────── // // `Client::on_key_event` classifies the event and folds it locally where this // client may answer it — text entry, and since improvise-bpq2 the grid, panel // and tile cursors too, which is why `sendViewport` must call `set_viewport` // and not `Client.viewport` (keys.rs carries the list, and what is still // upstream). Anything it cannot answer becomes a `ClientFrame::Key`, which // `Host::handle` answers with an empty vec: those keys are sent and do // nothing. That is settled rather than unfinished — the host must not know // the keymap — and closing one means teaching this client, not that arm. window.addEventListener("keydown", (e) => { // The inline editor owns the keyboard while it is open. It stops // propagation, so this is belt and braces for a focus we did not predict. if (editing) return; // BrowserKey drops anything held with the platform meta key precisely so // Cmd-R does not arrive as a bare `r`; do not claim it here either. if (e.metaKey) return; const frame = client.on_key_event(e.key, e.ctrlKey, e.altKey, e.shiftKey, e.metaKey); if (frame) { // A frame means the page claimed the key, so the browser must not also // act on it — an arrow that both moves the cursor and scrolls the page is // the worse of the two errors. Combinations under Ctrl or Alt are left // alone regardless: those are the browser's own shortcuts, and this // client resolves nothing under them anyway. if (!e.ctrlKey && !e.altKey) e.preventDefault(); transport.send(frame); } repaint(); }); // ── start ──────────────────────────────────────────────────────────────── await transport.ready; sendViewport(); // the handshake: the snapshot is built at this extent armWatchdog( WS_URL ? `${WS_URL} accepted the connection but sent no document` : "the in-page worker did not answer — this browser may not allow it (see console)", ); new ResizeObserver(sendViewport).observe(el.grid);