// Everything `web/app.js` decides that is not painting: no DOM, no browser // API, one `node --test` away. What belongs here: context/code/clients.md. /** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit * `ws://` is upgraded rather than honoured: context/code/clients.md. */ export function socketUrl(peer, pageProtocol) { const secure = pageProtocol === "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}`; } /** `#status` carries two independent facts, composed rather than overwritten: * a repaint must not quietly erase a connection error. */ export function composeStatus(shell, model) { return [shell, model].filter(Boolean).join(" · "); } /** 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). */ export function indicator(collapsed) { return collapsed ? "▶" : "▼"; } // 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. export const ROW_PX = 26; export const COL_PX = 110; /** How long a peer has to answer the handshake. Exported because the harness * must jump *past* it: a spec with its own copy can understate it silently * and every watchdog assertion then passes vacuously (clients.md). */ export const HANDSHAKE_TIMEOUT_MS = 8000; /** The extent a box of this size can show, in whole DATA cells: the frame is * charged for first, and at least one cell is always asked for. */ export function extentOf(box, frameCost, metrics = { rowPx: ROW_PX, colPx: COL_PX }) { return { rows: Math.max(1, Math.floor(box.height / metrics.rowPx) - frameCost.rows), cols: Math.max(1, Math.floor(box.width / metrics.colPx) - frameCost.cols), }; } /** What the grid's own frame costs, in whole cells — measured off the painted * model, never predicted. Inline group ROWS are deliberately not counted. */ export function frameCostOf(model) { return { rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0), cols: model.row_levels, }; } /** The grid, as a description: every decision made, nothing created. The page * decides nothing here and neither does this — which labels repeat, how many * header levels there are and which columns share a group all arrive * answered, by the same `AxisFrame` the TUI widget reads (improvise-anr9). */ export function gridPlan(model) { const cornerSpan = model.row_levels; const groups = model.col_groups.map((group) => ({ span: 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. text: group.label === null ? "" : `${indicator(group.collapsed)} ${group.label}`, })); // 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 the row's position. let dataRow = -1; const body = model.rows.map((row) => { if (row.kind === "group") { return { kind: "group", colSpan: model.row_levels + model.col_levels[0].length, text: `${indicator(row.collapsed)} ${row.label}`, }; } dataRow += 1; return { kind: "data", row: dataRow, labels: row.labels, cells: row.cells.map((cell, col) => { // 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"; return { row: dataRow, col, text: typing ? `${model.editing}▏` : cell.text, editText: cell.edit_text, editable: cell.editable, classes: { numeric: cell.numeric && !typing, error: cell.error, selected: cell.selected, typing, // `editable` is the server's answer relayed through Rust: a stored // cell. Drill is not on the wire, so an aggregate is not offered. editable: cell.editable, }, }; }), }; }); return { cornerSpan, groups, levels: model.col_levels, body }; } /** The page's two status facts and the handshake watchdog, which share a home * because the one rule between them is that a *failure disarms the timer*. */ export function createShell({ onStatus, timeoutMs, setTimer = setTimeout, clearTimer = clearTimeout }) { let shell = ""; let model = ""; let watchdog = null; let arms = 0; const paint = () => onStatus(composeStatus(shell, model)); return { setShell(text) { shell = text; paint(); }, setModel(text) { model = text; paint(); }, /** A peer that never answers the handshake. Deliberately about the *first * frame*, not either peer's own failure modes — clients.md. */ arm(message) { arms += 1; clearTimer(watchdog); watchdog = setTimer(() => { watchdog = null; this.setShell(message); }, timeoutMs); }, /** A frame arrived, so the peer answered: the watchdog has no question * left to ask. */ sawFrame() { clearTimer(watchdog); watchdog = null; }, /** A peer that failed *loudly* has already said something more precise, so * the watchdog must be DISARMED and not merely pre-empted (PR #13). */ fail(text) { this.sawFrame(); this.setShell(text); }, armed: () => watchdog !== null, /** How many times the watchdog has been armed. A test denying its message * needs this to be nonzero, or it is denying an unreachable event. */ timesArmed: () => arms, }; }