// The browser half of improvise-cr3, talking to either of two peers: five // string calls, no protocol knowledge. See context/code/clients.md. 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. Why, and when an explicit * `ws://` is upgraded rather than honoured: context/code/clients.md. */ 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"), formulas: document.getElementById("formulas"), views: document.getElementById("views"), tiles: document.getElementById("tiles"), mode: document.getElementById("mode"), hint: document.getElementById("hint"), help: document.getElementById("help"), status: document.getElementById("status"), viewName: document.getElementById("view-name"), }; await init(); const client = new Client(); // `#status` carries two independent facts, composed rather than overwritten: // a repaint must not 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(); } // What the grid's own frame costs, in whole cells, as of the last repaint — // measured off the painted model, never predicted. context/code/clients.md. let frameCost = { rows: 1, cols: 1 }; /** The extent this window can show, in whole DATA cells. */ function extent() { const box = el.grid.getBoundingClientRect(); return { rows: Math.max(1, Math.floor(box.height / ROW_PX) - frameCost.rows), cols: Math.max(1, Math.floor(box.width / COL_PX) - frameCost.cols), }; } function sendViewport() { const { rows, cols } = extent(); // `client.set_viewport`, NOT the associated `Client.viewport`: the same // frame, but it also RECORDS the window. Why that matters: clients.md. transport.send(client.set_viewport(rows, cols)); } // A peer that never answers the handshake. Deliberately about the *first // frame*, not either peer's own failure modes — context/code/clients.md. 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, so the // watchdog must be DISARMED and not merely pre-empted (PR #13 review). 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 console alone and the grid just stopped. 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 in arrival 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 unreachable = `cannot reach ${WS_URL} — is improvise-ws-server running?`; // Did this socket ever open? The page must record it itself: nothing on the // close event can discriminate. See context/code/clients.md. let opened = false; const ready = new Promise((resolve) => { socket.addEventListener("open", () => { opened = true; 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". socket.addEventListener("error", () => setShellFailure(unreachable)); // A socket that never opened did not "disconnect" (improvise-taxx). BOTH arms // route through `setShellFailure`, which disarms the watchdog — clients.md. socket.addEventListener("close", () => setShellFailure(opened ? "disconnected" : unreachable), ); 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 `help::hint_line`. The page spells no keys // itself; see `RenderModel::hint`. el.hint.textContent = model.hint; paintHelp(model); modelStatus = model.status; paintStatus(); el.viewName.textContent = model.view_name; el.grid.replaceChildren(gridTable(model)); // The flag is the client's answer, not the page's: `C` toggles it and the // TUI draws the panel on exactly the same condition (improvise-wsaw). el.categories.hidden = !model.category_panel_open; el.categories.replaceChildren(categoryList(model)); paintTiles(model); paintPanel(el.views, model.views, viewRow); paintPanel(el.formulas, model.formulas, formulaRow); // What the frame costs, so the NEXT viewport announcement is honest. // Inline group ROWS are deliberately NOT counted — context/code/clients.md. const next = { rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0), cols: model.row_levels, }; const changed = next.rows !== frameCost.rows || next.cols !== frameCost.cols; frameCost = next; if (changed) sendViewport(); } /** The help overlay. An empty `help` is what says it is closed -- the page * asks no question about the mode, and writes none of the content: the rows, * their keys and the page titles are `improvise_protocol::help`'s, the same * ones the TUI draws (improvise-ktv1). */ function paintHelp(model) { el.help.hidden = model.help.length === 0; if (el.help.hidden) { el.help.replaceChildren(); return; } const tabs = document.createElement("div"); tabs.className = "help-tabs"; model.help_titles.forEach((title, i) => { const tab = document.createElement("span"); tab.textContent = title; tab.className = i === model.help_page ? "help-tab active" : "help-tab"; tabs.append(tab); }); const body = document.createElement("dl"); body.className = "help-body"; for (const row of model.help) { const keys = document.createElement("dt"); keys.textContent = row.keys; keys.className = `help-${row.kind}`; body.append(keys); // A prose row has no description and spans both columns (its class says // so); emitting an empty `dd` beside it would leave a blank grid cell. if (row.description) { const desc = document.createElement("dd"); desc.textContent = row.description; body.append(desc); } } el.help.replaceChildren(tabs, body); } /** The grid, painted from a model in which every frame decision is already * made (improvise-anr9). The page decides nothing here: which labels repeat * and are therefore blank, how many header levels there are, which columns * share a group and whether it is collapsed all arrive answered, by the same * `AxisFrame` the TUI widget reads. */ function gridTable(model) { const table = document.createElement("table"); const head = table.createTHead(); const corner = () => { const th = document.createElement("th"); th.colSpan = model.row_levels; return th; }; // The column-group line, when there is one. An empty `col_groups` means // this grid has no column groups at all -- not that every group is blank. if (model.col_groups.length > 0) { const groups = head.insertRow(); groups.appendChild(corner()); for (const group of model.col_groups) { const th = document.createElement("th"); th.colSpan = group.span; // `label` is null for a run in no group; an empty STRING is a legal group // name, so this is a null check and not a truthiness test. th.textContent = group.label === null ? "" : indicator(group.collapsed) + " " + group.label; th.classList.add("group"); groups.appendChild(th); } } // One header line per level. A blank entry is repeat suppression, decided // in Rust -- never an absent label. for (const level of model.col_levels) { const tr = head.insertRow(); tr.appendChild(corner()); for (const label of level) { const th = document.createElement("th"); th.textContent = label; tr.appendChild(th); } } const body = table.createTBody(); // The cell coordinates a click sends are DATA row indices and a group header // is not a data row, so this counts rather than trusting `model.rows`. let r = -1; for (const row of model.rows) { const tr = body.insertRow(); if (row.kind === "group") { const th = document.createElement("th"); th.colSpan = model.row_levels + model.col_levels[0].length; th.textContent = indicator(row.collapsed) + " " + row.label; th.classList.add("group"); tr.appendChild(th); continue; } r += 1; // `r` is one binding shared by every closure below, so a click handler // that captured it would fire with the LAST row's index. Snapshot it. const dataRow = r; for (const label of row.labels) { const header = document.createElement("th"); header.scope = "row"; header.textContent = label; tr.appendChild(header); } row.cells.forEach((cell, c) => { const td = tr.insertCell(); // A keyboard edit is open when `model.editing` is a string, and it holds // what has been typed. Both facts are decided in Rust. 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. // Drill is not on the wire yet, so an aggregate is not offered. td.classList.toggle("editable", cell.editable); // Every cell moves the selection; an editable one also opens its editor, // which is the pointer's `i`. Not repainted in that branch: `edit` has // set `editing`, and a repaint would take the away. td.addEventListener("click", () => { const frame = client.click_cell(dataRow, c); if (frame) transport.send(frame); if (cell.editable) edit(td, dataRow, c, cell.edit_text); else repaint(); }); }); } return table; } /** The collapse glyph. `collapsed` is the fact, decided by the view and * carried on the frame; only this spelling is the page's (gotcha 26). */ function indicator(collapsed) { return collapsed ? "\u25b6" : "\u25bc"; } /** 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. 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. 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)); } /** A side panel. An empty row list is what says it is closed — the page asks * no question about the mode, exactly as `paintHelp` does not. */ function paintPanel(host, rows, build) { host.hidden = rows.length === 0; const list = document.createElement("ul"); for (const row of rows) list.appendChild(build(row)); host.replaceChildren(list); } function viewRow(row) { const li = document.createElement("li"); li.classList.toggle("selected", row.selected); li.classList.toggle("active", row.active); // The active marker is the TUI's, and the axis line is the shared // derivation's — the page spells neither the summary nor the axis letters. const name = document.createElement("span"); name.className = "name"; name.textContent = (row.active ? "▶ " : "  ") + row.name; li.append(name); if (row.axes) { const axes = document.createElement("span"); axes.className = "axes"; axes.textContent = row.axes; li.append(axes); } return li; } function formulaRow(row) { const li = document.createElement("li"); li.classList.toggle("selected", row.selected); li.textContent = row.text; return li; } /** The tile bar. `selected` is only ever true in tile-select mode, which is * decided in Rust. The strip is always laid out — context/code/clients.md. */ function paintTiles(model) { const tiles = model.tiles.map((tile, index) => { const span = document.createElement("span"); span.className = "tile"; span.classList.toggle("selected", tile.selected); span.textContent = `${tile.category} ${tile.axis}`; span.addEventListener("click", () => send(client.click_tile(index))); return span; }); el.tiles.replaceChildren(...tiles); } function categoryList(model) { const list = document.createElement("ul"); model.categories.forEach((row, index) => { const li = document.createElement("li"); li.className = row.is_category ? "category" : "item"; li.classList.toggle("selected", row.selected); // `indicator`, not a second pair of triangles: this inlined U+25B8/U+25BE // where the TUI and the page's own group headers draw U+25B6/U+25BC. li.textContent = row.is_category ? `${indicator(!row.expanded)} ${row.label} (${row.item_count}) ${row.axis}` : row.label; li.addEventListener("click", () => send(client.click_category(index))); list.appendChild(li); }); return list; } /** Send what a `click_*` call returned, and repaint. `null` means the click * resolved to nothing, which is an answer and not a failure. */ function send(frame) { if (frame) transport.send(frame); repaint(); } // The keyboard. `Client::on_key_event` folds locally where it can; what it // cannot answer is sent and does nothing, by design (context/code/clients.md). 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. Ctrl/Alt combinations are left alone: they are the browser's. 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);