// 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";
import {
COL_PX,
ROW_PX,
createShell,
extentOf,
frameCostOf,
gridPlan,
indicator,
socketUrl,
} from "./shell.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");
const WS_URL = WS_PEER ? socketUrl(WS_PEER, location.protocol) : null;
const DOC = params.get("doc") ?? "demo.improv";
const SEED = params.get("seed") ?? "demo.improv";
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();
// A peer that never answers the handshake, and the two facts `#status` shows.
// Both are decided in shell.js; this is the wire to the element.
const HANDSHAKE_TIMEOUT_MS = 8000;
const shell = createShell({
onStatus: (text) => {
el.status.textContent = text;
},
timeoutMs: HANDSHAKE_TIMEOUT_MS,
});
// 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 };
function sendViewport() {
const box = el.grid.getBoundingClientRect();
const metrics = { rowPx: ROW_PX, colPx: COL_PX };
const { rows, cols } = extentOf(box, frameCost, metrics);
// `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));
}
/** One frame, one fold, in arrival order. Never batch or reorder these. */
function onFrame(text) {
shell.sawFrame();
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.
shell.setShell("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", () =>
shell.fail("the worker sent a frame this page could not read"),
);
worker.addEventListener("error", (event) => {
shell.fail(`worker failed to start: ${event.message ?? "see console"}`);
console.error("improvise: worker error", event);
});
shell.setShell(`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;
shell.setShell(`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", () => shell.fail(unreachable));
// A socket that never opened did not "disconnect" (improvise-taxx). BOTH arms
// route through `shell.fail`, which disarms the watchdog — clients.md.
socket.addEventListener("close", () =>
shell.fail(opened ? "disconnected" : unreachable),
);
shell.setShell(`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) {
shell.setShell("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);
shell.setModel(model.status);
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 = frameCostOf(model);
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 plan in which every decision is already made
* (improvise-anr9, and `shell.js::gridPlan`). Nothing below decides anything:
* 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 plan = gridPlan(model);
const table = document.createElement("table");
const head = table.createTHead();
const corner = () => {
const th = document.createElement("th");
th.colSpan = plan.cornerSpan;
return th;
};
// The column-group line, when there is one. An empty `groups` means this
// grid has no column groups at all -- not that every group is blank.
if (plan.groups.length > 0) {
const groups = head.insertRow();
groups.appendChild(corner());
for (const group of plan.groups) {
const th = document.createElement("th");
th.colSpan = group.span;
th.textContent = group.text;
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 plan.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();
for (const row of plan.body) {
const tr = body.insertRow();
if (row.kind === "group") {
const th = document.createElement("th");
th.colSpan = row.colSpan;
th.textContent = row.text;
th.classList.add("group");
tr.appendChild(th);
continue;
}
for (const label of row.labels) {
const header = document.createElement("th");
header.scope = "row";
header.textContent = label;
tr.appendChild(header);
}
for (const cell of row.cells) {
const td = tr.insertCell();
td.textContent = cell.text;
for (const [name, on] of Object.entries(cell.classes)) {
td.classList.toggle(name, on);
}
// 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(cell.row, cell.col);
if (frame) transport.send(frame);
if (cell.editable) edit(td, cell.row, cell.col, cell.editText);
else repaint();
});
}
}
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.
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
shell.arm(
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);