feat(wasm-client,web): the DOM renderer for the browser grid (improvise-cr3)
The last blocker of improvise-1ey. Mostly assembly: `Client` already had the fold, the key classifier and `set_cell`, `CellDisplay::id` already said what a cell is (ADR 002), and `flatten_cat_tree` already owned the category panel's flattening. What was missing was the three joints between them. `render.rs` is the one that matters. The DOM half is JavaScript and JavaScript here is untested, so every question with a *silent* wrong answer is answered in Rust under test and crosses as decided data: `editable` is a fact about the `CellId` variant (Record only — an Aggregate is a drill target and `Drill` is not on the wire yet, and `None` is "no projected cell", not "aggregate"), the selection is placed by subtracting the scroll offset because `GridCache` is viewport-indexed while the cursor is not, and the category panel is `flatten_cat_tree` rather than a second walk of the cache. `socket.rs` frames what `improvise-ws-server` actually speaks, which is not what `wire.rs` speaks: it has `Viewport` (the handshake — a snapshot built at extent zero is an empty grid), it batches `Commands`, and it has no `Key` variant. The two enums are duplicated because a native crate cannot be a wasm dependency; `improvise-ws-server` is a dev-dependency so the compatibility is checked through its own types, and a companion test proves the check is discriminating on the variant names that would drift. `web/` is the shell, and it is thin on purpose: four string calls in and out, no protocol knowledge, no framework. Gates: 1047 passing (was 1031), clippy and fmt clean, and `cargo check -p improvise-wasm-client --target wasm32-unknown-unknown` clean. Verified end to end headlessly — the wasm-pack artifact driven from node over a real websocket to a real `improvise-ws-server`: 1,200 read, "98765" typed, "98,765" projected back. The DOM painting itself is unverified; there is no headless browser here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
This commit is contained in:
+167
@@ -0,0 +1,167 @@
|
||||
// The browser half of improvise-cr3, kept as thin as it can be made.
|
||||
//
|
||||
// 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.
|
||||
// Four calls are the whole harness —
|
||||
//
|
||||
// Client.viewport(rows, cols) -> the handshake frame, and every resize
|
||||
// client.on_frame(json) -> fold one socket 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"
|
||||
//
|
||||
// — 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.
|
||||
|
||||
import init, { Client } from "./pkg/improvise_wasm_client.js";
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const WS_URL = params.get("ws") ?? `ws://${location.hostname || "127.0.0.1"}:8787`;
|
||||
|
||||
// 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"),
|
||||
status: document.getElementById("status"),
|
||||
viewName: document.getElementById("view-name"),
|
||||
};
|
||||
|
||||
await init();
|
||||
const client = new Client();
|
||||
const socket = new WebSocket(WS_URL);
|
||||
|
||||
/** 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() {
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
const { rows, cols } = extent();
|
||||
socket.send(Client.viewport(rows, cols));
|
||||
}
|
||||
|
||||
let painted = -1;
|
||||
let editing = null; // {row, col} while an input is open, so a repaint cannot steal it
|
||||
|
||||
function repaint() {
|
||||
const model = JSON.parse(client.render_model());
|
||||
if (model.revision === painted || editing) return;
|
||||
painted = model.revision;
|
||||
|
||||
el.mode.textContent = model.mode;
|
||||
el.status.textContent = model.status;
|
||||
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();
|
||||
td.textContent = cell.text;
|
||||
td.classList.toggle("numeric", cell.numeric);
|
||||
td.classList.toggle("error", cell.error);
|
||||
td.classList.toggle("selected", cell.selected);
|
||||
// `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));
|
||||
});
|
||||
});
|
||||
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) {
|
||||
if (editing) return;
|
||||
editing = { row, col };
|
||||
const original = td.textContent;
|
||||
const input = document.createElement("input");
|
||||
input.value = original;
|
||||
td.replaceChildren(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const close = (commit) => {
|
||||
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) socket.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);
|
||||
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;
|
||||
}
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
sendViewport(); // the handshake: the snapshot is built at this extent
|
||||
el.status.textContent = "connected";
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
// One frame, one fold, in arrival order. Never batch or reorder these.
|
||||
client.on_frame(event.data);
|
||||
repaint();
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
el.status.textContent = "disconnected";
|
||||
});
|
||||
|
||||
new ResizeObserver(sendViewport).observe(el.grid);
|
||||
Reference in New Issue
Block a user