Files
improvise/web/app.js
T
Edward Langley 9aef855d5f fix: the viewport reserves the real frame, and a fallback width counts cells
Copilot's two "previously missed" findings, both correct.

**`extent()` reserved one header row and one header column.** That was right
until this PR: the column header is now one line per level plus an optional
group line, and the row header is `row_levels` columns wide. Announcing a
viewport the page cannot show matters because `#grid` has `overflow: auto` —
the DOM would take a scroll position of its own while the client's cursor
offsets were computed from the larger window, putting the selected cell off
screen. The cost is measured off the painted model and re-announced when it
changes.

Inline group *rows* are deliberately **not** counted, although they occupy
space: they depend on how many rows the window asked for, so counting them
would be a feedback loop — a smaller window crosses fewer groups and asks
for a larger one. The three facts that are counted depend only on the axes.
The residue is a little over-fetch, the direction `ROW_PX` already errs in.

**`fit_to_labels` measured its fallback width in Unicode scalars.** It is
documented as the width a column is drawn at while `ColWidths` catches up,
and the authoritative widths are in display cells, so `日本` came out two
cells wide where it needs four. Now measured with `UnicodeWidthStr`, like
`header_widths` and like the TUI.
`a_column_without_its_width_yet_is_measured_in_display_cells` pins it, and
restoring `chars().count()` fails it with `[2]` against `[4]`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:22:30 -07:00

478 lines
20 KiB
JavaScript

// 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();
}
// What the grid's own frame costs, in whole cells, as of the last repaint.
// One header row and one header column was right until improvise-anr9: the
// column header is now one row PER LEVEL plus an optional group row, the row
// header is `row_levels` columns wide, and inline group headers take a row
// each. Reserving one of each announces a viewport the page cannot show, and
// `#grid` has `overflow: auto`, so the DOM would acquire a scroll position of
// its own while the client's cursor offsets were computed from the larger
// window — putting the selected cell off screen.
//
// It is measured off the painted model rather than predicted, and defaults to
// the old one-and-one before the first paint, which is the only moment there
// is nothing to measure.
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. A client learns its window only
// from what it announced -- `row_frame.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));
// What the frame costs, so the NEXT viewport announcement is honest: the
// header lines, plus a group line if there is one, plus the row header's
// width in columns.
//
// Inline group ROWS are deliberately not counted, even though they take
// space. They depend on how many rows the window asked for, so counting
// them would make this a feedback loop — a smaller window fetches fewer
// rows, which crosses fewer groups, which asks for a larger window. The
// three facts below depend only on the axes, so a repaint cannot chase
// its own tail. The cost is a little over-fetch, which is the direction
// ROW_PX already errs in.
const next = {
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
cols: model.row_levels,
};
if (next.rows !== frameCost.rows || next.cols !== frameCost.cols) {
frameCost = next;
sendViewport();
}
}
/** 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;
th.textContent = group.label ? 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 rows rather than trusting the
// position in `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 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, dataRow, c, cell.edit_text));
});
}
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, 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 <input>, 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);