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:
+66
-2
@@ -51,11 +51,30 @@ export function extentOf(box, frameCost, metrics = { rowPx: ROW_PX, colPx: COL_P
|
||||
* 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),
|
||||
// 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) => ({
|
||||
// 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 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
|
||||
@@ -109,7 +128,52 @@ export function gridPlan(model) {
|
||||
};
|
||||
});
|
||||
|
||||
return { cornerSpan, groups, levels: model.col_levels, body };
|
||||
// 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,
|
||||
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), edit: cell.editText };
|
||||
}
|
||||
|
||||
/** …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, editText: dataset.edit ?? "" };
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
Reference in New Issue
Block a user