Files
improvise/web/shell.js
T

387 lines
15 KiB
JavaScript

// 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}`;
}
/** How long a transient message stays on screen. Long enough to read a
* sentence, short enough that it is gone before it is furniture. */
export const TOAST_MS = 6000;
/** `ViewState::status_msg` is STATE, not an event: a repaint re-offers the
* string that is already on screen, so dismissal keys off message IDENTITY
* and never off a timer alone. What that costs: context/code/clients.md. */
export function createToasts({
onShow,
onHide,
ttlMs = TOAST_MS,
setTimer = setTimeout,
clearTimer = clearTimeout,
}) {
// What was last ACCEPTED, kept after the toast hides. That is the whole
// mechanism: a repaint offering it again is recognised and dropped.
let offered = "";
let timer = null;
return {
offer(text) {
const next = text ?? "";
if (next === offered) return;
offered = next;
clearTimer(timer);
timer = null;
if (next === "") {
onHide();
return;
}
onShow(next);
timer = setTimer(() => {
timer = null;
onHide();
}, ttlMs);
},
};
}
/** The title bar's one line, in the order and spelling `src/draw.rs::draw_title`
* uses. The three facts are the host's; only the joining is here (clients.md). */
export function documentTitle(name, dirty, storedAs) {
return `${name}${storedAs ? ` (${storedAs})` : ""}${dirty ? " [+]" : ""}`;
}
/** 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 ? "▶" : "▼";
}
/** Where the deployment notice's open/closed state is remembered. Per-viewer
* and per-origin by construction: it never reaches Rust and never travels. */
export const NOTICE_KEY = "improvise.notice.open";
/** Whether a visitor who has never answered sees the notice OPEN. It is a
* product call and the argument for `false` is in context/code/clients.md. */
export const NOTICE_OPEN_BY_DEFAULT = false;
/** What a stored notice state MEANS. Everything that is not one of the two
* spellings — absent, empty, a value from an older page, a string a private
* window handed back — is "unanswered", which is the default and not an
* error: storage is allowed to forget, and a page that treated forgetting as
* a third state would flicker between them. */
export function noticeOpen(stored) {
if (stored === "open") return true;
if (stored === "closed") return false;
return NOTICE_OPEN_BY_DEFAULT;
}
/** …and the inverse, so the two spellings have exactly one home. */
export function noticeState(open) {
return open ? "open" : "closed";
}
// 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 {
// The totals strip is chrome on the same terms as a header level: always
// there when there are totals, and never a data row (improvise-p7lh).
rows:
model.col_levels.length +
(model.col_groups.length > 0 ? 1 : 0) +
(model.totals.length > 0 ? 1 : 0),
cols: model.row_levels,
};
}
/** The page bar: one entry per category fixing which records are visible. The
* Page/Filter split is `cycle`, decided in Rust — gotcha 27, clients.md. */
export function pageEntries(model) {
return model.page_coords.map((axis, index) => ({
index,
// The TUI's own spelling (`src/ui/grid.rs`); only the spelling is local,
// exactly as `indicator`'s is.
text: `${axis.cat} = ${axis.selected}`,
// `cycle` is null for a Filter axis, which constrains without being
// navigable: there is no position to show and no key that would move it.
position: axis.cycle ? `${axis.cycle.at + 1}/${axis.cycle.count}` : "",
navigable: Boolean(axis.cycle),
// The items a menu offers, RELAYED from `cycle` — the one home of the
// Page/Filter split, so a Filter axis offers none (improvise-rw6l).
items: axis.cycle
? axis.cycle.items.map((label, item) => ({
item,
label,
selected: item === axis.cycle.at,
}))
: [],
}));
}
/** The down-arrow. Present exactly where there is a menu to open, which is
* `navigable` and never "is this on the page bar" (gotcha 27). */
export const MENU_ARROW = "▾";
/** What a delegated click on the page bar or its menu MEANS. `null` where the
* node names neither, which is every gap between the chips. */
export function pageItemAction(dataset) {
const axis = whole(dataset.axis);
if (axis === null) return null;
const item = whole(dataset.item);
// A chip carries an axis and no item: it OPENS the menu. A menu row carries
// both: it picks. One attribute apart, so nothing infers from structure.
return item === null ? { kind: "open", axis } : { kind: "pick", axis, item };
}
/** What a key does while the page menu is open. The menu owns the keyboard
* exactly as the inline editor does — `context/code/clients.md`. */
export function menuKey(key, { count, active }) {
if (count === 0) return { kind: "close" };
switch (key) {
case "Escape":
case "Tab":
return { kind: "close" };
case "Enter":
case " ":
return { kind: "pick", item: active };
case "ArrowDown":
case "j":
return { kind: "move", item: (active + 1) % count };
case "ArrowUp":
case "k":
return { kind: "move", item: (active - 1 + count) % count };
case "Home":
return { kind: "move", item: 0 };
case "End":
return { kind: "move", item: count - 1 };
// Everything else is SWALLOWED rather than passed on: a menu that let
// `hjkl` reach the grid would scroll the thing it is anchored to.
default:
return { kind: "ignore" };
}
}
/** 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,
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,
},
};
}),
};
});
// An EMPTY strip means this grid has no totals — not a row of blanks, and
// not "not caught up yet" (gotcha 33). So `null`, not an empty row.
const totals =
model.totals.length === 0
? null
: {
span: cornerSpan,
// The SECOND spelling of this word — `src/ui/grid.rs` has the other.
// Named as debt rather than shared: context/code/clients.md.
label: "Total",
cells: model.totals.map((cell) => ({
text: cell.text,
classes: { numeric: cell.numeric, error: cell.error },
})),
};
return { cornerSpan, groups, levels: model.col_levels, body, totals };
}
/** What a data cell carries on its node, so one delegated handler can read
* back the answers `gridPlan` already made (improvise-b8ot, clients.md). */
export function cellAttrs(cell) {
return { row: String(cell.row), col: String(cell.col) };
}
/** …and the inverse: what a delegated mousedown MEANS. `null` where the event
* did not land on a data cell, which is every header and the corner. */
export function cellAction(dataset, editable) {
const row = whole(dataset.row);
const col = whole(dataset.col);
// A node that carries no coordinates is not a cell: every header and the
// corner reach here, and none of them means anything.
if (row === null || col === null) return null;
return { row, col, editable };
}
/** The same, for the surfaces whose rows are named by one index: the panels,
* the tile bar and the category tree. `null` where there is no row. */
export function indexAction(dataset) {
return whole(dataset.index);
}
/** A non-negative whole number off an attribute, or `null`. An ABSENT
* attribute and the string `""` are both "no index", and `Number` says 0. */
function whole(text) {
if (typeof text !== "string" || text.trim() === "") return null;
const value = Number(text);
return Number.isInteger(value) && value >= 0 ? value : null;
}
/** What the worker just posted. A protocol frame is a STRING and a shell
* notice is an OBJECT; nothing else crosses that port (clients.md). */
export function classifyWorkerMessage(data) {
if (typeof data === "string") return { kind: "frame", text: data };
if (data && typeof data === "object" && typeof data.kind === "string") {
return { kind: "notice", notice: data };
}
return { kind: "unknown", data };
}
/** What a boot or save notice READS AS. The kinds are decided in Rust
* (`improvise_worker_server::boot`); only the wording is the page's. */
export function noticeText(notice) {
const where = notice.document ? ` — ${notice.document}` : "";
const why = notice.detail ? ` (${notice.detail})` : "";
switch (notice.kind) {
case "ReadOnly":
return `read-only${where}: another tab or window has it open. Close it and reload to edit.`;
case "BootFailed":
return `the in-page worker could not start${where}${why}`;
case "SeedMissing":
return `started an EMPTY document${where}: its starting copy could not be fetched${why}`;
case "SaveFailed":
return `NOT SAVED${where}${why}`;
// A kind this page does not know is still a failure, and saying so beats
// saying nothing — the reason it carries is the point (improvise-1wft).
default:
return `the in-page worker reported ${notice.kind}${where}${why}`;
}
}
/** Whether a notice means the session is unusable, as opposed to degraded.
* Only `BootFailed` is fatal: read-only, empty and unsaved all still paint. */
export function isFatal(notice) {
return notice.kind === "BootFailed";
}
/** 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*.
*
* The two facts go to two SINKS and are never joined: a repaint reaches
* `onModel` and cannot address `onShell` at all. That is the structural form
* of the rule the `·` join used to keep by convention — context/code/clients.md. */
export function createShell({
onShell,
onModel,
timeoutMs,
setTimer = setTimeout,
clearTimer = clearTimeout,
}) {
let watchdog = null;
let answered = null;
let arms = 0;
const disarm = () => {
clearTimer(watchdog);
watchdog = null;
};
return {
setShell(text) {
onShell(text);
},
setModel(text) {
onModel(text);
},
/** A peer that never answers the handshake. Deliberately about the *first
* frame*, not either peer's own failure modes — clients.md. */
arm(message, { answer = null } = {}) {
arms += 1;
answered = answer;
disarm();
watchdog = setTimer(() => {
watchdog = null;
this.setShell(message);
}, timeoutMs);
},
/** A frame arrived, so the peer answered. Only the FIRST, and only if no
* failure spoke first, may say so (improvise-i0v9, clients.md). */
sawFrame() {
if (watchdog !== null && answered !== null) this.setShell(answered);
disarm();
},
/** 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) {
disarm();
this.setShell(text);
},
armed: () => watchdog !== null,
/** The window this shell was built with. Exposed so a spec can compare the
* RUNNING page's value against the one it imported — improvise-669b. */
timeoutMs: () => timeoutMs,
/** 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,
};
}