feat(browser): paint the page bar and the totals strip, and delegate the pointer
improvise-p7lh: `GridCache::page_coords` and `GridCache::totals` were projected to every session and folded correctly by every client, and NO front end read either. The shipped front page — `web/demo.improv` is `Quarter: page, Q1` — drew a grid filtered to Q1 with nothing saying so, and `[`/`]` resolve locally off that same `page_coords`, so a user could page through quarters watching every number change with no way to tell which one they were looking at. `RenderModel` gains `page_coords` and `totals`, fed straight off `state.grid`. The Page/Filter split is asked of `PageAxis::cycle` rather than re-derived from `items.is_some()`, which disagrees with it on an empty item list (gotcha 27). The totals are RELAYED, never summed in the client: the sum runs over every row including ones a viewport scrolled past (gotcha 44). `web/tests/render_surface.test.js` gains the direction that would have caught this when improvise-n10 added the fields. The existing guard is closed under what `RenderModel` chose to expose, so a cache field that never reaches it is invisible to it; the new one asks that every `pub` field of every widget cache is read somewhere the browser runs, or is named as debt with a reason. `grid.col_widths` is the one entry, pointing at improvise-pcje. improvise-b8ot: the five clickable surfaces moved from one listener per node, reallocated inside the render loops on every repaint, to one handler per stable delegation root — the user's ruling. The operands are carried on the node as data attributes and read back by a pure `cellAction`/`indexAction` in `web/shell.js`, so the handler is a LOOKUP rather than a second derivation of which data row was clicked: `gridPlan` counts data rows because a group header is not one, and walking the table back up would disagree the moment one appears. The editor's own keydown/blur stay direct — one transient node, one at a time, and `blur` does not bubble. All four documented behaviours a naive delegation loses are preserved and each is now asserted in `harness/tests/contract.spec.js`: mousedown rather than click (asserted by dispatching mousedown ALONE), `preventDefault` and only over a cell, the conditionally skipped repaint (asserted by the editor's survival), and closing a live editor before composing the click. Gates: 1598 passed / 0 failed / 7 ignored over 37 binaries (baseline 1595); node --test 47 over 6 files (baseline 36 over 5); Playwright 47 passed / 4 skipped (baseline 43 / 4); clippy, fmt and --check-evidence clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
+100
-22
@@ -6,15 +6,19 @@ import {
|
||||
COL_PX,
|
||||
HANDSHAKE_TIMEOUT_MS,
|
||||
ROW_PX,
|
||||
cellAction,
|
||||
cellAttrs,
|
||||
classifyWorkerMessage,
|
||||
createShell,
|
||||
documentTitle,
|
||||
extentOf,
|
||||
frameCostOf,
|
||||
gridPlan,
|
||||
indexAction,
|
||||
indicator,
|
||||
isFatal,
|
||||
noticeText,
|
||||
pageEntries,
|
||||
socketUrl,
|
||||
} from "./shell.js";
|
||||
|
||||
@@ -33,6 +37,7 @@ const el = {
|
||||
formulas: document.getElementById("formulas"),
|
||||
views: document.getElementById("views"),
|
||||
tiles: document.getElementById("tiles"),
|
||||
pages: document.getElementById("pages"),
|
||||
mode: document.getElementById("mode"),
|
||||
minibuffer: document.getElementById("minibuffer"),
|
||||
hint: document.getElementById("hint"),
|
||||
@@ -211,16 +216,15 @@ function repaint() {
|
||||
model.doc_dirty,
|
||||
model.doc_stored_as,
|
||||
);
|
||||
paintPages(model);
|
||||
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, (i) => client.click_view(i));
|
||||
paintPanel(el.formulas, model.formulas, formulaRow, (i) =>
|
||||
client.click_formula(i),
|
||||
);
|
||||
paintPanel(el.views, model.views, viewRow);
|
||||
paintPanel(el.formulas, model.formulas, formulaRow);
|
||||
|
||||
// The selection must be on screen. `#grid` scrolls on its own — ROW_PX and
|
||||
// COL_PX under-estimate and group rows are uncharged — so a selection the
|
||||
@@ -286,6 +290,27 @@ function paintHelp(model) {
|
||||
el.help.replaceChildren(tabs, body);
|
||||
}
|
||||
|
||||
/** The page bar: which item each fixed category is fixed to. Without it a
|
||||
* grid filtered to Q1 says nothing at all, and `[`/`]` move it in silence
|
||||
* (improvise-p7lh). An empty list is what says there are none. */
|
||||
function paintPages(model) {
|
||||
const entries = pageEntries(model);
|
||||
el.pages.hidden = entries.length === 0;
|
||||
el.pages.replaceChildren(
|
||||
...entries.map((entry) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "page-axis";
|
||||
// Only a Page axis is navigable; a Filter one constrains without being
|
||||
// something `[`/`]` can move (gotcha 27).
|
||||
span.classList.toggle("navigable", entry.navigable);
|
||||
span.textContent = entry.position
|
||||
? `${entry.text} (${entry.position})`
|
||||
: entry.text;
|
||||
return span;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -352,20 +377,29 @@ function gridTable(model) {
|
||||
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 <input> away.
|
||||
// On MOUSEDOWN, and defaulted away: from an editing state the input's
|
||||
// blur repainted first and this <td> was gone before `click` could fire
|
||||
// (improvise-0x9u). See context/code/clients.md.
|
||||
td.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault();
|
||||
if (editing) editing.close(false);
|
||||
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();
|
||||
});
|
||||
// The plan's own answers, CARRIED rather than re-derived: `row` counts
|
||||
// data rows, which walking the table back up would get wrong (b8ot).
|
||||
Object.assign(td.dataset, cellAttrs(cell));
|
||||
}
|
||||
}
|
||||
|
||||
// The totals strip, PROJECTED: the sum runs over every row including the
|
||||
// ones this viewport scrolled past, so nothing here adds anything up.
|
||||
if (plan.totals) {
|
||||
const foot = table.createTFoot();
|
||||
const tr = foot.insertRow();
|
||||
const label = document.createElement("th");
|
||||
label.scope = "row";
|
||||
label.colSpan = plan.totals.span;
|
||||
label.textContent = plan.totals.label;
|
||||
tr.appendChild(label);
|
||||
for (const cell of plan.totals.cells) {
|
||||
const td = tr.insertCell();
|
||||
td.textContent = cell.text;
|
||||
for (const [name, on] of Object.entries(cell.classes)) {
|
||||
td.classList.toggle(name, on);
|
||||
}
|
||||
td.classList.add("total");
|
||||
}
|
||||
}
|
||||
return table;
|
||||
@@ -411,6 +445,8 @@ function edit(td, row, col, seed) {
|
||||
};
|
||||
editing = { row, col, close };
|
||||
|
||||
// These two stay DIRECT while the five surfaces above are delegated: one
|
||||
// transient node, one at a time, and `blur` does not bubble (improvise-b8ot).
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") dismiss(true);
|
||||
else if (e.key === "Escape") dismiss(false);
|
||||
@@ -428,12 +464,12 @@ function edit(td, row, col, seed) {
|
||||
|
||||
/** 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, clicked) {
|
||||
function paintPanel(host, rows, build) {
|
||||
host.hidden = rows.length === 0;
|
||||
const list = document.createElement("ul");
|
||||
rows.forEach((row, index) => {
|
||||
const li = build(row);
|
||||
li.addEventListener("click", () => send(clicked(index)));
|
||||
li.dataset.index = String(index);
|
||||
list.appendChild(li);
|
||||
});
|
||||
host.replaceChildren(list);
|
||||
@@ -473,7 +509,7 @@ function paintTiles(model) {
|
||||
span.className = "tile";
|
||||
span.classList.toggle("selected", tile.selected);
|
||||
span.textContent = `${tile.category} ${tile.axis}`;
|
||||
span.addEventListener("click", () => send(client.click_tile(index)));
|
||||
span.dataset.index = String(index);
|
||||
return span;
|
||||
});
|
||||
el.tiles.replaceChildren(...tiles);
|
||||
@@ -490,7 +526,7 @@ function categoryList(model) {
|
||||
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)));
|
||||
li.dataset.index = String(index);
|
||||
list.appendChild(li);
|
||||
});
|
||||
return list;
|
||||
@@ -503,6 +539,48 @@ function send(frame) {
|
||||
repaint();
|
||||
}
|
||||
|
||||
// The pointer. ONE handler per surface, on a root OUTSIDE what is redrawn:
|
||||
// `replaceChildren` replaces an element's children, so these outlive every
|
||||
// repaint (user ruling, improvise-b8ot). See context/code/clients.md.
|
||||
|
||||
/** A surface whose rows are named by one index. */
|
||||
function delegateIndex(root, selector, act) {
|
||||
root.addEventListener("click", (event) => {
|
||||
const node = event.target.closest(selector);
|
||||
if (!node || !root.contains(node)) return;
|
||||
const index = indexAction(node.dataset);
|
||||
if (index !== null) send(act(index));
|
||||
});
|
||||
}
|
||||
|
||||
delegateIndex(el.categories, "li[data-index]", (i) => client.click_category(i));
|
||||
delegateIndex(el.views, "li[data-index]", (i) => client.click_view(i));
|
||||
delegateIndex(el.formulas, "li[data-index]", (i) => client.click_formula(i));
|
||||
delegateIndex(el.tiles, "span[data-index]", (i) => client.click_tile(i));
|
||||
|
||||
// MOUSEDOWN, not click, and deliberately: from an editing state the input's
|
||||
// blur repaints first and the <td> is gone before `click` could fire
|
||||
// (improvise-0x9u). Delegation does not change that — the target is detached.
|
||||
el.grid.addEventListener("mousedown", (event) => {
|
||||
const td = event.target.closest("td[data-row]");
|
||||
// Defaulted away only once this really is a cell: a header had no listener
|
||||
// before, so preventing its default would be a new behaviour.
|
||||
if (!td || !el.grid.contains(td)) return;
|
||||
// `editable` is the class the plan already set; the coordinates are the
|
||||
// attributes it already wrote. Nothing here walks the table.
|
||||
const action = cellAction(td.dataset, td.classList.contains("editable"));
|
||||
if (!action) return;
|
||||
event.preventDefault();
|
||||
// The live editor closes FIRST, before the click is composed. Order matters.
|
||||
if (editing) editing.close(false);
|
||||
const frame = client.click_cell(action.row, action.col);
|
||||
if (frame) transport.send(frame);
|
||||
// Every cell moves the selection; an editable one also opens its editor.
|
||||
// NOT repainted in that branch: a repaint would take the <input> away.
|
||||
if (action.editable) edit(td, action.row, action.col, action.editText);
|
||||
else 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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user