feat(protocol): the grid's frame on the wire, as AxisFrame
`GridCache::row_labels`/`col_labels` were `Vec<String>` built by `GridLayout::row_label`, which slash-joins a multi-level tuple — the exact rendering `two_row_categories_produce_cross_product_labels` asserts must not appear. So the protocol carried a form of the labels the TUI itself considered wrong, and `improvise-wasm-client/src/render.rs` drew precisely that form: the browser showed `Food/Alice` where the TUI showed two header columns, with no groups and no collapse indicators (gotcha 44). Both fields become one `AxisFrame` per axis, carrying the five facts gotcha 44 enumerates: the categories (hence the header levels), the label *tuples* and the inline group headers, the group a position sits in, and each group's collapse state. `record_col` stays off — session-local, keys pending drill edits, correct to keep off. It is one type and not a `row_levels` beside `row_labels`, because two answers to "what are the labels" is the disease (improvise-edp). The rules that used to be written twice are methods on it and are now called from both front ends: `shows_level` (repeat suppression, improvise-jjx's fix), `group_of`, `body_from`, and `header_widths` — the row-header width rule, which existed twice already, inline in `render_grid` and again in `viewport::compute_row_header_width` (improvise-1125). `GridWidget` loses its `&View`: nothing in it reads the view any more. Evidence. `the_whole_frame_is_unchanged` — the exact-buffer characterization over a grid with row groups, column groups, a page axis, multi-level headers and a total row — passes byte for byte, so the TUI is unchanged. The new `tests/grid_frame_agreement.rs` drives the widget into a ratatui buffer and the browser client through a `Snapshot` of the *same* cache, and compares what each draws; re-introducing the slash-join on either side was injected and watched to fail before the test was kept. `tests/web_demo_model.rs::the_web_demo_has_one_category_per_visible_axis` is deleted: it guarded the gap this closes, and a consolidation that keeps its compatibility test is unfinished. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+64
-13
@@ -118,7 +118,7 @@ function sendViewport() {
|
||||
const { rows, cols } = extent();
|
||||
// `client.set_viewport`, NOT the associated `Client.viewport`: the same
|
||||
// frame, but it also RECORDS the window. A client learns its window only
|
||||
// from what it announced -- `row_labels.len()` is the window clipped to the
|
||||
// from what it announced -- `row_frame.len()` is the window clipped to the
|
||||
// grid, so the two differ exactly at the bottom edge, which is where the
|
||||
// scroll arithmetic matters. Without this the client cannot clamp, so every
|
||||
// cursor key goes upstream into `Host::handle`'s empty vec and the arrow
|
||||
@@ -244,24 +244,69 @@ function repaint() {
|
||||
el.categories.replaceChildren(categoryList(model));
|
||||
}
|
||||
|
||||
/** The grid, painted from a model in which every frame decision is already
|
||||
* made (improvise-anr9). The page decides nothing here: which labels repeat
|
||||
* and are therefore blank, how many header levels there are, which columns
|
||||
* share a group and whether it is collapsed all arrive answered, by the same
|
||||
* `AxisFrame` the TUI widget reads. */
|
||||
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 head = table.createTHead();
|
||||
const corner = () => {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = label;
|
||||
head.appendChild(th);
|
||||
th.colSpan = model.row_levels;
|
||||
return th;
|
||||
};
|
||||
|
||||
// The column-group line, when there is one. An empty `col_groups` means
|
||||
// this grid has no column groups at all -- not that every group is blank.
|
||||
if (model.col_groups.length > 0) {
|
||||
const groups = head.insertRow();
|
||||
groups.appendChild(corner());
|
||||
for (const group of model.col_groups) {
|
||||
const th = document.createElement("th");
|
||||
th.colSpan = group.span;
|
||||
th.textContent = group.label ? indicator(group.collapsed) + " " + group.label : "";
|
||||
th.classList.add("group");
|
||||
groups.appendChild(th);
|
||||
}
|
||||
}
|
||||
|
||||
// One header line per level. A blank entry is repeat suppression, decided
|
||||
// in Rust -- never an absent label.
|
||||
for (const level of model.col_levels) {
|
||||
const tr = head.insertRow();
|
||||
tr.appendChild(corner());
|
||||
for (const label of level) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = label;
|
||||
tr.appendChild(th);
|
||||
}
|
||||
}
|
||||
|
||||
const body = table.createTBody();
|
||||
model.rows.forEach((row, r) => {
|
||||
// The cell coordinates a click sends are DATA row indices, and a group
|
||||
// header is not a data row -- so this counts rows rather than trusting the
|
||||
// position in `model.rows`.
|
||||
let r = -1;
|
||||
for (const row of model.rows) {
|
||||
const tr = body.insertRow();
|
||||
const header = document.createElement("th");
|
||||
header.scope = "row";
|
||||
header.textContent = row.label;
|
||||
tr.appendChild(header);
|
||||
if (row.kind === "group") {
|
||||
const th = document.createElement("th");
|
||||
th.colSpan = model.row_levels + model.col_levels[0].length;
|
||||
th.textContent = indicator(row.collapsed) + " " + row.label;
|
||||
th.classList.add("group");
|
||||
tr.appendChild(th);
|
||||
continue;
|
||||
}
|
||||
r += 1;
|
||||
|
||||
for (const label of row.labels) {
|
||||
const header = document.createElement("th");
|
||||
header.scope = "row";
|
||||
header.textContent = label;
|
||||
tr.appendChild(header);
|
||||
}
|
||||
|
||||
row.cells.forEach((cell, c) => {
|
||||
const td = tr.insertCell();
|
||||
@@ -283,10 +328,16 @@ function gridTable(model) {
|
||||
td.classList.toggle("editable", cell.editable);
|
||||
if (cell.editable) td.addEventListener("click", () => edit(td, r, c, cell.edit_text));
|
||||
});
|
||||
});
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
function indicator(collapsed) {
|
||||
return collapsed ? "\u25b6" : "\u25bc";
|
||||
}
|
||||
|
||||
/** 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, seed) {
|
||||
|
||||
Reference in New Issue
Block a user