Files
improvise/crates/improvise-wasm-client/src/render.rs
T
Edward Langley 301a74821a 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>
2026-09-19 22:43:47 -07:00

608 lines
24 KiB
Rust

//! What the page paints: the client's state with every rendering decision
//! already made (improvise-cr3).
//!
//! The DOM half of this client is JavaScript, and JavaScript in this repo is
//! untested. So the split is drawn to make that acceptable rather than to
//! make it comfortable: **every question with a wrong answer is answered
//! here, in Rust, under test**, and what crosses to the page is a list of
//! rows of cells that each already know whether they are editable, numeric,
//! erroneous and selected. The page's job is `document.createElement`.
//!
//! Three decisions in particular are here because getting them wrong is
//! silent:
//!
//! - **Editable is a fact about the id, not about the text.** Only
//! `CellId::Record` may be written (ADR 002). `CellId::Aggregate` is a
//! *drill* target and `Drill` is not on the wire yet, so it renders as
//! non-editable rather than as a control that does nothing. And `None` is
//! **not** "aggregate" — it is "no projected cell here": a padded slot, a
//! blanked one, or a records-mode column that renames a coordinate across
//! the whole row (gotcha 1) rather than writing one.
//! - **The selection is absolute and the grid is not.** `GridCache` is
//! indexed by position *within the viewport*, while `ViewState`'s
//! `selected` and `row_offset`/`col_offset` are positions within the whole
//! grid. Subtracting the offset is the entire conversion, and the trap is
//! that forgetting it looks right at the top of a document and wrong after
//! the first scroll. A selection outside the window highlights nothing.
//! - **The category panel is [`flatten_cat_tree`], not a second walk of the
//! cache.** That function exists in `improvise-protocol` precisely so this
//! end and `move-panel-cursor` share one flattening (improvise-edp); the
//! page never sees `CategoryTreeCache` at all.
//! - **The grid's frame is `AxisFrame`, and every header decision is made
//! here** (improvise-anr9). The multi-level header levels, the repeat
//! suppression, the group runs and their collapse state come off the same
//! `AxisFrame` methods the TUI widget calls, so the page receives labels
//! already blanked where they repeat and group runs already spanned. Until
//! this landed the protocol carried `GridLayout::row_label`'s slash-joined
//! tuple and the page drew `Food/Alice` where the TUI drew two header
//! columns — a derivation the TUI's own test says is wrong, shipped to the
//! other front end.
//!
//! Nothing here is on the wire. It is a *projection of a projection* — the
//! client's own caches shaped for one renderer — so it may change freely
//! without a protocol conversation.
use improvise_protocol::viewmodel::{CatTreeEntry, flatten_cat_tree};
use improvise_protocol::{AxisFrame, CellDisplay, CellId, FrameEntry};
use serde::Serialize;
use crate::{Client, ClientState};
/// One frame's worth of everything the page draws.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderModel {
/// The revision this model was built at. A page that stores it can skip
/// a repaint without re-serializing.
pub revision: u64,
/// The mode's own label, off `AppMode` — the client does not spell it.
pub mode: String,
pub status: String,
/// The document's active view, as this client knows it (gotcha 29).
pub view_name: String,
/// The column header, one entry per level, each with one label per
/// column and `""` where the label repeats the column to its left.
/// Level-major because that is how it is drawn: one line per level.
pub col_levels: Vec<Vec<String>>,
/// The runs of columns that share a group, in column order. Empty when
/// the column axis has no groups at all — which is a fact, not a gap,
/// and the page draws no group line for it.
pub col_groups: Vec<RenderColGroup>,
/// How many row-header levels every [`RenderRow::Data`] carries.
pub row_levels: usize,
pub rows: Vec<RenderRow>,
/// The category panel, flattened by the one flattening.
pub categories: Vec<RenderCatRow>,
/// Whether the panel is open, so the page does not decide.
pub category_panel_open: bool,
/// The keys this mode offers, off `AppMode::hint_text()` — the same
/// string the TUI's footer draws.
///
/// The page shows it rather than spelling any keys of its own, which is
/// what keeps a second front end from growing its own idea of the
/// bindings (design-principles §5). It also makes the modes this client
/// can *enter* but not yet *draw* — `Help` above all — state what they
/// are instead of looking like a broken grid.
pub hint: String,
/// What the user has typed so far, while a keyboard edit is open on the
/// selected cell — `None` when none is.
///
/// The page draws this in place of the selected cell's own text. The
/// *decision* — is an edit open, and what is in it — is made here,
/// because it is `AppMode::is_editing()` and the `edit` buffer, and a
/// second front end must not have to know either
/// (design-principles §5). Without it a browser that resolved `i`
/// locally would change its mode indicator and then swallow every
/// keystroke invisibly.
pub editing: Option<String>,
}
/// A grid row: either a group header spanning the row, or data.
///
/// An enum rather than a `label` plus an `is_group` flag, so a group header
/// cannot carry cells and a data row cannot carry a collapse state
/// (design-principles §2). It is serialized with a `kind` tag because the
/// consumer is JavaScript.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum RenderRow {
Group {
label: String,
collapsed: bool,
},
Data {
/// One label per row-header level, already blanked where it repeats
/// the row above — the page never decides suppression.
labels: Vec<String>,
cells: Vec<RenderCell>,
},
}
impl RenderRow {
/// The cells of a data row. A group header spans the grid and has none,
/// which is the empty slice rather than a panic — the caller asking for
/// cells of a header row is asking a reasonable question about a row it
/// did not choose.
pub fn cells(&self) -> &[RenderCell] {
match self {
RenderRow::Data { cells, .. } => cells,
RenderRow::Group { .. } => &[],
}
}
/// The row header's labels, one per level. A group header has one label
/// of its own.
pub fn labels(&self) -> &[String] {
match self {
RenderRow::Data { labels, .. } => labels,
RenderRow::Group { .. } => &[],
}
}
}
/// A run of columns under one group header.
///
/// `label` is empty for a run of columns in no group, which keeps the runs a
/// partition of the columns: the spans sum to the column count, so a page
/// laying them out as `colspan` cannot leave a hole.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderColGroup {
pub label: String,
pub collapsed: bool,
pub span: usize,
}
/// One cell, with every question already answered.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderCell {
pub text: String,
/// Right-align. The server decided this; it is echoed, not re-derived.
pub numeric: bool,
pub error: bool,
/// May this client compose a write for it? True for `CellId::Record`
/// alone.
pub editable: bool,
pub selected: bool,
/// What an editor opened on this cell should start with — the stored
/// value, not the formatted one. See `CellDisplay::edit_text`: seeding
/// from `text` and committing turns `1200` into the string `"1,200"`.
pub edit_text: String,
}
impl RenderCell {
fn of(cell: &CellDisplay, selected: bool) -> Self {
Self {
text: cell.text.clone(),
numeric: cell.numeric,
error: cell.error,
editable: matches!(cell.id, Some(CellId::Record(_))),
selected,
edit_text: cell.edit_text.clone().unwrap_or_default(),
}
}
}
/// One row of the category panel.
///
/// `item_count` and `expanded` are meaningful on a category row and are the
/// header's own facts; an item row carries neither, which is why the two are
/// `Option` rather than sentinels.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderCatRow {
pub label: String,
/// True for a category header, false for an item under one.
pub is_category: bool,
/// The axis initial for a category header (`""` for `Axis::None`, which
/// is a real answer and not a truncation — gotcha 26).
pub axis: String,
pub item_count: Option<usize>,
pub expanded: Option<bool>,
/// Under `ViewState::cat_panel_cursor`, which indexes this very list.
pub selected: bool,
}
impl ClientState {
/// Build the page's frame from the caches and the view slice.
pub fn render_model(&self) -> RenderModel {
let cursor = self.view.cursor();
let (sel_row, sel_col) = cursor.selected;
let entries = flatten_cat_tree(&self.categories, &self.view.expanded_cats);
let rows = &self.grid.row_frame;
let cols = &self.grid.col_frame;
RenderModel {
revision: self.revision,
mode: self.view.mode.label().to_string(),
status: self.view.status_msg.clone(),
view_name: self.view.active_view.clone(),
col_levels: col_levels(cols),
col_groups: col_groups(cols),
row_levels: rows.levels(),
rows: rows
.body_from(0)
.iter()
.scan(0usize, |data_idx, entry| {
let row = match entry {
FrameEntry::Group(group) => RenderRow::Group {
label: group.name.clone(),
collapsed: group.collapsed,
},
FrameEntry::Item(_) => {
let r = *data_idx;
*data_idx += 1;
RenderRow::Data {
labels: (0..rows.levels())
.map(|level| match rows.shows_level(r, 0, level) {
true => rows.label(r, level).to_string(),
false => String::new(),
})
.collect(),
cells: (0..cols.len())
.map(|c| {
// The one conversion: viewport
// position + offset is the absolute
// position the cursor names.
let selected = r + cursor.row_offset == sel_row
&& c + cursor.col_offset == sel_col;
match self.grid.cell(r, c) {
Some(cell) => RenderCell::of(cell, selected),
None => RenderCell {
selected,
..RenderCell::default()
},
}
})
.collect(),
}
}
};
Some(row)
})
.collect(),
categories: entries
.iter()
.enumerate()
.map(|(i, entry)| RenderCatRow::of(entry, i == self.view.cat_panel_cursor))
.collect(),
category_panel_open: self.view.category_panel_open,
hint: self.view.mode.hint_text().to_string(),
editing: self
.view
.mode
.is_editing()
.then(|| self.view.buffers.get("edit").cloned().unwrap_or_default()),
}
}
}
impl RenderCatRow {
fn of(entry: &CatTreeEntry, selected: bool) -> Self {
match entry {
CatTreeEntry::Category {
name,
item_count,
expanded,
axis,
} => Self {
label: name.clone(),
is_category: true,
axis: axis.initial().to_string(),
item_count: Some(*item_count),
expanded: Some(*expanded),
selected,
},
CatTreeEntry::Item { item_name, .. } => Self {
label: item_name.clone(),
is_category: false,
axis: String::new(),
item_count: None,
expanded: None,
selected,
},
}
}
}
/// The column header lines: one per level, one label per column, blanked
/// where the label repeats. Exactly `AxisFrame::shows_level`, which is what
/// the TUI's header loop calls — the suppression rule is not restated here.
fn col_levels(cols: &AxisFrame) -> Vec<Vec<String>> {
(0..cols.levels())
.map(|level| {
(0..cols.len())
.map(|c| match cols.shows_level(c, 0, level) {
true => cols.label(c, level).to_string(),
false => String::new(),
})
.collect()
})
.collect()
}
/// The column groups as runs, by walking `AxisFrame::group_of` across the
/// columns and coalescing equal answers.
///
/// It returns nothing at all when no column is in a group, because "this
/// grid has no column groups" and "every column's group is blank" are
/// different statements and the page draws a line for only one of them —
/// the same asymmetry `GridCache::totals` has (gotcha 33).
fn col_groups(cols: &AxisFrame) -> Vec<RenderColGroup> {
let mut runs: Vec<RenderColGroup> = Vec::new();
let mut any = false;
for c in 0..cols.len() {
let group = cols.group_of(c);
any |= group.is_some();
let label = group.map(|g| g.name.clone()).unwrap_or_default();
let collapsed = group.is_some_and(|g| g.collapsed);
match runs.last_mut() {
Some(run) if run.label == label && run.collapsed == collapsed => run.span += 1,
_ => runs.push(RenderColGroup {
label,
collapsed,
span: 1,
}),
}
}
match any {
true => runs,
false => Vec::new(),
}
}
impl Client {
pub fn model(&self) -> RenderModel {
self.state().render_model()
}
pub fn model_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(&self.model())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init;
use improvise_core::view::Axis;
use improvise_protocol::{
CategoryRow, CategoryTreeUpdate, Command, GridUpdate, Projection, RecordCell, Snapshot,
ViewState,
};
fn surrogate() -> improvise_protocol::RecordHash {
serde_json::from_str("\"fe4c8e5e9cbd578e0d388f4b2b5a7879\"").expect("hex")
}
fn aggregate() -> improvise_protocol::AggregateHash {
serde_json::from_str("\"5b05bbcd2364099f68a49dbf9099e075\"").expect("hex")
}
fn record_id() -> CellId {
CellId::Record(RecordCell {
record: surrogate(),
measure: Some("Revenue".into()),
})
}
/// A 2x2 grid whose four cells cover the three id shapes plus a padded
/// slot, so one fixture answers every editability question.
fn client_with_grid(view: ViewState) -> Client {
let mut client = init();
client.bootstrap(Snapshot {
view,
seed: vec![
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North", "South"]))),
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue", "Total"]))),
Projection::Grid(GridUpdate::Cells(vec![
((0, 0), CellDisplay::number("1200").with_id(record_id())),
(
(0, 1),
CellDisplay::number("1900").with_id(CellId::Aggregate(aggregate())),
),
((1, 0), CellDisplay::text("pending")),
])),
],
});
client
}
/// ADR 002's permission, as the page sees it. The three cases are three
/// different facts, and only the first is a cell a user may type into.
#[test]
fn only_a_record_cell_renders_as_editable() {
let model = client_with_grid(ViewState::default()).model();
assert!(
model.rows[0].cells()[0].editable,
"a stored cell is editable"
);
assert!(
!model.rows[0].cells()[1].editable,
"an aggregate is a drill target, and Drill is not on the wire yet"
);
assert!(
!model.rows[1].cells()[0].editable,
"id: None is not 'aggregate' — it is 'no projected cell', and not writable either"
);
assert!(
!model.rows[1].cells()[1].editable,
"and neither is a slot fit_to_labels padded"
);
}
/// The numeric/error flags are the server's answers, echoed. The client
/// must not re-derive them from the text (that is how the two ends
/// drift), so the test asserts the echo rather than a parse.
#[test]
fn the_servers_formatting_facts_are_echoed_not_re_derived() {
let model = client_with_grid(ViewState::default()).model();
assert!(model.rows[0].cells()[0].numeric);
assert!(!model.rows[1].cells()[0].numeric);
assert_eq!(model.rows[0].cells()[0].text, "1200");
assert_eq!(model.col_levels[0], vec!["Revenue", "Total"]);
assert_eq!(model.rows[1].labels(), ["South"]);
}
/// The conversion that looks right until the first scroll: the grid is
/// viewport-indexed, the cursor is not. With `row_offset = 4`, absolute
/// row 5 is *render* row 1.
#[test]
fn the_selection_is_placed_by_subtracting_the_scroll_offset() {
let mut view = ViewState::default();
view.cursor_mut().selected = (5, 1);
view.cursor_mut().row_offset = 4;
view.cursor_mut().col_offset = 1;
let model = client_with_grid(view).model();
assert!(
model.rows[1].cells()[0].selected,
"absolute (5,1) under offsets (4,1) is render position (1,0)"
);
let elsewhere: usize = model
.rows
.iter()
.flat_map(|r| r.cells())
.filter(|c| c.selected)
.count();
assert_eq!(elsewhere, 1, "exactly one cell is selected");
}
/// A cursor scrolled off the projected window selects nothing, rather
/// than wrapping onto whatever cell happens to sit at that index.
#[test]
fn a_selection_outside_the_window_highlights_nothing() {
let mut view = ViewState::default();
view.cursor_mut().selected = (40, 0);
let model = client_with_grid(view).model();
assert!(
!model
.rows
.iter()
.flat_map(|r| r.cells())
.any(|c| c.selected),
"a cursor the server has not projected a row for highlights nothing"
);
}
/// The panel comes from `flatten_cat_tree` and honours this client's own
/// `expanded_cats` — which is why the flattening is per-client and
/// derived rather than sent (improvise-edp).
#[test]
fn the_category_panel_is_the_shared_flattening() {
let mut client = init();
client
.state_mut()
.fold(&Command::Project(Projection::CategoryTree(
CategoryTreeUpdate::Categories(vec![
CategoryRow {
name: "Region".into(),
items: vec!["North".into(), "South".into()],
axis: Axis::Row,
},
CategoryRow {
name: "Measure".into(),
items: vec!["Revenue".into()],
axis: Axis::Column,
},
]),
)));
let collapsed = client.model().categories;
assert_eq!(collapsed.len(), 2, "collapsed categories show headers only");
assert_eq!(collapsed[0].axis, "R");
assert_eq!(collapsed[0].item_count, Some(2));
assert_eq!(collapsed[0].expanded, Some(false));
client
.state_mut()
.view
.expanded_cats
.insert("Region".into());
client.state_mut().view.cat_panel_cursor = 2;
let expanded = client.model().categories;
assert_eq!(expanded.len(), 4, "Region's two items appear under it");
assert!(!expanded[1].is_category);
assert_eq!(expanded[1].label, "North");
assert!(
expanded[2].selected,
"cat_panel_cursor indexes the flattened list, items included"
);
assert_eq!(
expanded[3].label, "Measure",
"and the next category follows its items"
);
}
/// The mode's label is `AppMode`'s own, so a page never spells one.
#[test]
fn the_mode_label_comes_off_the_mode() {
let mut client = init();
client.state_mut().fold(&Command::SetStatus("saved".into()));
let model = client.model();
assert_eq!(model.mode, improvise_protocol::AppMode::Normal.label());
assert_eq!(model.status, "saved");
assert!(
model.hint.contains("i:edit"),
"the page draws the mode's own hint rather than spelling keys: {}",
model.hint
);
assert_eq!(model.revision, client.revision());
}
/// A keyboard edit has to be *visible*, and what is visible is a
/// decision made here rather than in the page.
///
/// The failure this rules out is quiet: a client that resolved `i`
/// locally would change its mode label, put the typing into
/// `buffers["edit"]`, and draw the cell's old value the whole time — so
/// the user sees a keystroke do nothing, types over it, and commits
/// something they never read. `None` outside an editing mode is the
/// other half: a stale buffer must not draw an editor that is not open.
#[test]
fn a_keyboard_edit_in_progress_is_part_of_the_render_model() {
let mut client = client_with_grid(ViewState::default());
assert_eq!(
client.model().editing,
None,
"nothing is being edited in Normal"
);
client
.state_mut()
.fold(&Command::ChangeMode(improvise_protocol::AppMode::editing()));
client.state_mut().fold(&Command::SetBuffer {
name: "edit".into(),
value: "12".into(),
});
assert_eq!(client.model().editing.as_deref(), Some("12"));
client
.state_mut()
.fold(&Command::ChangeMode(improvise_protocol::AppMode::Normal));
assert_eq!(
client.model().editing,
None,
"leaving the mode closes the editor even with the buffer still set"
);
}
/// The whole model crosses as JSON, so the page needs no bindings.
#[test]
fn the_render_model_crosses_as_json() {
let json = client_with_grid(ViewState::default())
.model_json()
.expect("serializes");
let value: serde_json::Value = serde_json::from_str(&json).expect("is JSON");
assert_eq!(value["rows"][0]["cells"][0]["editable"], true);
assert_eq!(value["rows"][0]["cells"][1]["editable"], false);
assert_eq!(value["col_levels"][0][0], "Revenue");
assert_eq!(
value["rows"][0]["kind"], "data",
"a row says which of the two it is; the page does not guess"
);
assert_eq!(value["rows"][0]["labels"][0], "North");
assert_eq!(
value["rows"][0]["cells"][0]["edit_text"], "1200",
"an editor seeds from the stored value, never from the drawn one"
);
}
}