From 301a74821a1359accf532d9a3806f3bb9f44fe53 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sat, 19 Sep 2026 22:43:47 -0700 Subject: [PATCH] feat(protocol): the grid's frame on the wire, as AxisFrame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GridCache::row_labels`/`col_labels` were `Vec` 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) --- Cargo.lock | 3 + crates/improvise-command/src/ui/host.rs | 8 +- crates/improvise-command/src/ui/project.rs | 117 ++++- crates/improvise-command/src/ui/reduce.rs | 6 +- crates/improvise-command/src/ui/viewport.rs | 56 +- crates/improvise-protocol/Cargo.toml | 4 + crates/improvise-protocol/src/frame.rs | 4 +- crates/improvise-protocol/src/lib.rs | 9 +- crates/improvise-protocol/src/reduce.rs | 10 +- crates/improvise-protocol/src/render.rs | 482 +++++++++++++++--- crates/improvise-wasm-client/src/keys.rs | 25 +- crates/improvise-wasm-client/src/lib.rs | 25 +- crates/improvise-wasm-client/src/render.rs | 230 +++++++-- crates/improvise-wasm-client/src/socket.rs | 17 +- crates/improvise-wasm-client/src/state.rs | 41 +- .../tests/browser_round_trip.rs | 17 +- .../improvise-wasm-client/tests/lockstep.rs | 2 +- crates/improvise-worker-server/src/worker.rs | 4 +- .../tests/post_message.rs | 20 +- crates/improvise-ws-server/tests/socket.rs | 4 +- src/draw.rs | 1 - src/ui/grid.rs | 238 +++------ tests/web_demo_model.rs | 36 +- web/app.js | 77 ++- web/style.css | 7 + 25 files changed, 979 insertions(+), 464 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18ddea3d..74d35025 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -842,6 +842,8 @@ dependencies = [ "improvise-core", "improvise-formula", "improvise-io", + "improvise-protocol", + "improvise-wasm-client", "indexmap", "pest", "pest_derive", @@ -937,6 +939,7 @@ dependencies = [ "improvise-core", "serde", "serde_json", + "unicode-width", ] [[package]] diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index 82779e6d..6ea9ab9d 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -413,7 +413,7 @@ mod tests { assert_eq!(snapshot.view.active_view, host.app().view_state.active_view); let grid = folded_grid(std::slice::from_ref(&frame.msg)); assert_eq!( - grid.row_labels, + grid.row_frame.leaf_labels(), vec!["North".to_string(), "South".to_string()], "the seed did not carry the document's rows" ); @@ -426,7 +426,7 @@ mod tests { fn the_reported_extent_bounds_the_snapshot() { let mut host = host(); let (_, frame) = host.connect(1, 1); - assert_eq!(folded_grid(&[frame.msg]).row_labels.len(), 1); + assert_eq!(folded_grid(&[frame.msg]).row_frame.len(), 1); } /// A resize with no document change still owes this client a grid: the @@ -440,7 +440,7 @@ mod tests { let mut seen = vec![connect.msg]; seen.extend(to(&frames, id)); assert_eq!( - folded_grid(&seen).row_labels, + folded_grid(&seen).row_frame.leaf_labels(), vec!["North".to_string(), "South".to_string()], "growing the window did not deliver the row that came into view" ); @@ -729,7 +729,7 @@ mod tests { "client {id:?} was not rebased: {msgs:?}" ); assert_eq!( - folded_grid(&msgs).row_labels, + folded_grid(&msgs).row_frame.leaf_labels(), vec!["Widget".to_string()], "client {id:?} was rebased onto the wrong document" ); diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 3ea84702..a9bb8fec 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -47,17 +47,18 @@ use improvise_core::model::Model; use improvise_core::model::category::MEASURE_CATEGORY; use improvise_core::model::cell::CellValue; use improvise_core::model::record::{AggregateHash, RecordHash}; +use improvise_core::view::layout::AxisEntry; use improvise_core::view::layout::RecordCol; use improvise_core::view::{Axis, View}; use improvise_core::workbook::Workbook; use improvise_io::import::analyzer::{DateComponent, FieldKind}; use improvise_io::import::wizard::{ImportWizard, WizardStep}; use improvise_protocol::{ - Basis, BasisCache, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, - Command, FormulaCache, GridCache, GridExtent, GridUpdate, Projection, RecordCell, Rejection, - Snapshot, TileBarCache, TileEntry, ViewListCache, ViewState, ViewSummary, WidgetCache, - WizardCache, WizardDateField, WizardFieldKind, WizardProposal, WizardStepKind, basis, - reduce_view, + AxisFrame, Basis, BasisCache, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, + CellId, Command, FormulaCache, FrameEntry, FrameGroup, GridCache, GridExtent, GridUpdate, + Projection, RecordCell, Rejection, Snapshot, TileBarCache, TileEntry, ViewListCache, ViewState, + ViewSummary, WidgetCache, WizardCache, WizardDateField, WizardFieldKind, WizardProposal, + WizardStepKind, basis, reduce_view, }; use crate::ui::app::App; @@ -194,8 +195,18 @@ pub fn grid_of(app: &App, viewport: Viewport) -> GridCache { let widths = compute_col_widths(&workbook.model, layout, comma, decimals); GridCache { - row_labels: rows.clone().map(|r| layout.row_label(r)).collect(), - col_labels: cols.clone().map(|c| layout.col_label(c)).collect(), + row_frame: axis_frame( + &layout.row_cats, + &layout.row_items, + workbook.active_view(), + rows.clone(), + ), + col_frame: axis_frame( + &layout.col_cats, + &layout.col_items, + workbook.active_view(), + cols.clone(), + ), col_widths: cols .clone() .map(|c| widths.get(c).copied().unwrap_or(0)) @@ -220,6 +231,66 @@ pub fn grid_of(app: &App, viewport: Viewport) -> GridCache { } } +/// One axis's frame, windowed to the data indices `window` names. +/// +/// Three edge rules, and every one of them is about a group header, because +/// a header occupies a row without consuming a data index (gotcha 1's +/// cousin: the data index counts items, the visual position counts entries). +/// +/// - **A header above the window comes with it.** A column window opening +/// inside `Q1` must still be able to say the column is in `Q1` +/// (`AxisFrame::group_of`), so the headers since the last in-window item +/// are carried. `AxisFrame::body_from` then declines to *paint* them, +/// which is the cut `GridLayout::data_row_to_visual` has always made. +/// - **Consecutive headers all come.** `[Q1 (collapsed), Q2, Jan]` is a real +/// shape: only the nearest wins `group_of`, but dropping the far one would +/// lose a collapsed group the user has to be able to expand again. +/// - **A trailing header comes only when the window reaches the last item.** +/// A collapsed group at the bottom of the grid has no items to hang off +/// and is still a row the user must see; but a window scrolled to the +/// middle must not be handed the bottom of the document. +fn axis_frame( + cats: &[String], + items: &[AxisEntry], + view: &View, + window: std::ops::Range, +) -> AxisFrame { + let total = items + .iter() + .filter(|e| matches!(e, AxisEntry::DataItem(_))) + .count(); + let mut data_idx = 0usize; + let mut entries: Vec = Vec::new(); + let mut pending: Vec = Vec::new(); + for entry in items { + match entry { + AxisEntry::GroupHeader { + cat_name, + group_name, + } => pending.push(FrameEntry::Group(FrameGroup { + cat: cat_name.clone(), + name: group_name.clone(), + collapsed: view.is_group_collapsed(cat_name, group_name), + })), + AxisEntry::DataItem(tuple) => { + let idx = data_idx; + data_idx += 1; + if window.contains(&idx) { + entries.append(&mut pending); + entries.push(FrameEntry::Item(tuple.clone())); + } + } + } + } + if window.end >= total { + entries.append(&mut pending); + } + AxisFrame { + cats: cats.to_vec(), + entries, + } +} + /// The totals strip: each visible column summed over **every** row of the /// grid, not just the rows in the viewport. /// @@ -614,11 +685,11 @@ pub fn grid_updates(mirror: &GridCache, desired: &GridCache) -> Vec if mirror.extent != desired.extent { updates.push(GridUpdate::Extent(desired.extent)); } - if mirror.row_labels != desired.row_labels { - updates.push(GridUpdate::RowLabels(desired.row_labels.clone())); + if mirror.row_frame != desired.row_frame { + updates.push(GridUpdate::RowFrame(desired.row_frame.clone())); } - if mirror.col_labels != desired.col_labels { - updates.push(GridUpdate::ColLabels(desired.col_labels.clone())); + if mirror.col_frame != desired.col_frame { + updates.push(GridUpdate::ColFrame(desired.col_frame.clone())); } if mirror.col_widths != desired.col_widths { updates.push(GridUpdate::ColWidths(desired.col_widths.clone())); @@ -1467,8 +1538,8 @@ mod tests { cols: 1, }, ); - assert_eq!(cache.row_labels.len(), 1, "precondition: a real window"); - assert_eq!(cache.col_labels.len(), 1); + assert_eq!(cache.row_frame.len(), 1, "precondition: a real window"); + assert_eq!(cache.col_frame.len(), 1); assert_eq!(cache.extent, GridExtent { rows: 3, cols: 2 }); } @@ -1526,7 +1597,7 @@ mod tests { .1 .iter() .filter_map(|p| match p { - Projection::Grid(GridUpdate::RowLabels(l)) => Some(l.clone()), + Projection::Grid(GridUpdate::RowFrame(f)) => Some(f.leaf_labels()), _ => None, }) .collect(); @@ -1543,8 +1614,8 @@ mod tests { fn labels_are_emitted_before_the_cells_they_frame() { let mirror = GridCache::default(); let desired = GridCache { - row_labels: vec!["r0".into()], - col_labels: vec!["c0".into()], + row_frame: AxisFrame::flat(["r0"]), + col_frame: AxisFrame::flat(["c0"]), col_widths: vec![8], cells: vec![vec![CellDisplay::number("1")]], ..GridCache::default() @@ -1555,7 +1626,7 @@ mod tests { .position(|u| matches!(u, GridUpdate::Cells(_))) .expect("a cell update"); for (i, update) in updates.iter().enumerate() { - if matches!(update, GridUpdate::RowLabels(_) | GridUpdate::ColLabels(_)) { + if matches!(update, GridUpdate::RowFrame(_) | GridUpdate::ColFrame(_)) { assert!( i < first_cells, "labels must come before cells: {updates:?}" @@ -1573,15 +1644,15 @@ mod tests { #[test] fn a_reshaped_grid_folds_to_the_desired_state() { let small = GridCache { - row_labels: vec!["r0".into()], - col_labels: vec!["c0".into()], + row_frame: AxisFrame::flat(["r0"]), + col_frame: AxisFrame::flat(["c0"]), col_widths: vec![8], cells: vec![vec![CellDisplay::number("1")]], ..GridCache::default() }; let large = GridCache { - row_labels: vec!["r0".into(), "r1".into()], - col_labels: vec!["c0".into(), "c1".into()], + row_frame: AxisFrame::flat(["r0", "r1"]), + col_frame: AxisFrame::flat(["c0", "c1"]), col_widths: vec![8, 8], cells: vec![ vec![CellDisplay::number("1"), CellDisplay::number("2")], @@ -1634,7 +1705,7 @@ mod tests { .into_iter() .flat_map(|(_, p)| p) .filter_map(|p| match p { - Projection::Grid(GridUpdate::RowLabels(l)) => Some(l), + Projection::Grid(GridUpdate::RowFrame(f)) => Some(f.leaf_labels()), _ => None, }) .collect(); @@ -2014,7 +2085,7 @@ mod tests { .into_iter() .flat_map(|(_, p)| p) .filter_map(|p| match p { - Projection::Grid(GridUpdate::RowLabels(l)) => Some(l), + Projection::Grid(GridUpdate::RowFrame(f)) => Some(f.leaf_labels()), _ => None, }) .collect(); diff --git a/crates/improvise-command/src/ui/reduce.rs b/crates/improvise-command/src/ui/reduce.rs index dd55d28a..7c083676 100644 --- a/crates/improvise-command/src/ui/reduce.rs +++ b/crates/improvise-command/src/ui/reduce.rs @@ -416,7 +416,7 @@ mod tests { use crate::ui::app::AppMode; use crate::view::View; use crate::workbook::Workbook; - use improvise_protocol::Projection; + use improvise_protocol::{AxisFrame, Projection}; fn app() -> App { let mut wb = Workbook::new("Test"); @@ -545,7 +545,9 @@ mod tests { let before = format!("{:?}", app.view_state); let outbound = reduce_checked( &mut app, - &Command::Project(Projection::Grid(GridUpdate::RowLabels(vec!["x".into()]))), + &Command::Project(Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([ + "x", + ])))), ); assert!(outbound.is_empty()); assert_eq!(before, format!("{:?}", app.view_state)); diff --git a/crates/improvise-command/src/ui/viewport.rs b/crates/improvise-command/src/ui/viewport.rs index 894bf539..b819e639 100644 --- a/crates/improvise-command/src/ui/viewport.rs +++ b/crates/improvise-command/src/ui/viewport.rs @@ -3,6 +3,8 @@ use unicode_width::UnicodeWidthStr; +use improvise_protocol::{AxisFrame, FrameEntry}; + use crate::format::format_f64; use crate::model::Model; use crate::view::{AxisEntry, GridLayout}; @@ -10,8 +12,11 @@ use crate::view::{AxisEntry, GridLayout}; /// Minimum column width — enough for short numbers/labels + 1 char gap. pub const MIN_COL_WIDTH: u16 = 5; pub const MAX_COL_WIDTH: u16 = 32; -pub const MIN_ROW_HEADER_W: u16 = 4; -pub const MAX_ROW_HEADER_W: u16 = 24; + +// The row-header bounds moved to `improvise-protocol` with the rule that +// clamps to them (improvise-1125): a browser sizing its own row header must +// reach the same numbers, and it cannot depend on this crate. +pub use improvise_protocol::{MAX_ROW_HEADER_W, MIN_ROW_HEADER_W}; /// Compute adaptive column widths for pivot mode (header labels + cell values). /// Header widths use the widest *individual* level label (not the joined @@ -75,32 +80,29 @@ pub fn compute_col_widths( .collect() } -/// Compute the total row header width from the layout's row items. +/// The total row-header width for a layout. +/// +/// The rule itself is `AxisFrame::header_widths` in `improvise-protocol`, +/// which is where both front ends reach it (improvise-1125). This is the +/// one caller that holds a `GridLayout` rather than a projected frame — +/// `App::visible_cols`, deciding how many columns fit — so it shapes the +/// layout into a frame and asks the same question. It does **not** reach +/// for a `View`, because a collapsed flag cannot change a width. pub fn compute_row_header_width(layout: &GridLayout) -> u16 { - let n_row_levels = layout.row_cats.len().max(1); - let data_row_items: Vec<&Vec> = layout - .row_items - .iter() - .filter_map(|e| { - if let AxisEntry::DataItem(v) = e { - Some(v) - } else { - None - } - }) - .collect(); - let sub_widths: Vec = (0..n_row_levels) - .map(|d| { - let max_label = data_row_items - .iter() - .filter_map(|v| v.get(d)) - .map(|s| s.width() as u16) - .max() - .unwrap_or(0); - (max_label + 1).clamp(MIN_ROW_HEADER_W, MAX_ROW_HEADER_W) - }) - .collect(); - sub_widths.iter().sum() + AxisFrame { + cats: layout.row_cats.clone(), + entries: layout + .row_items + .iter() + .filter_map(|e| match e { + AxisEntry::DataItem(v) => Some(FrameEntry::Item(v.clone())), + AxisEntry::GroupHeader { .. } => None, + }) + .collect(), + } + .header_widths() + .iter() + .sum() } /// Count how many columns fit starting from `col_offset` given the available width. diff --git a/crates/improvise-protocol/Cargo.toml b/crates/improvise-protocol/Cargo.toml index d139c04a..7f350f5c 100644 --- a/crates/improvise-protocol/Cargo.toml +++ b/crates/improvise-protocol/Cargo.toml @@ -9,6 +9,10 @@ repository = "https://github.com/fiddlerwoaroof/improvise" [dependencies] improvise-core = { path = "../improvise-core" } serde = { version = "1", features = ["derive"] } +# For the one width rule both front ends measure a header label with +# (`AxisFrame::header_widths`). A terminal column is a display cell, not a +# `char`, and the TUI has always measured it this way. +unicode-width = "^0.2" [dev-dependencies] serde_json = "1" diff --git a/crates/improvise-protocol/src/frame.rs b/crates/improvise-protocol/src/frame.rs index 405dc7ef..6f497312 100644 --- a/crates/improvise-protocol/src/frame.rs +++ b/crates/improvise-protocol/src/frame.rs @@ -160,7 +160,7 @@ impl ServerFrame { #[cfg(test)] mod tests { use super::*; - use crate::{GridUpdate, Projection, ViewState}; + use crate::{AxisFrame, GridUpdate, Projection, ViewState}; #[test] fn a_client_frame_round_trips_through_json() { @@ -194,7 +194,7 @@ mod tests { seed: vec![Projection::Formulas(vec!["Profit = Revenue".into()])], }), ServerFrame::Commands(vec![Command::Project(Projection::Grid( - GridUpdate::RowLabels(vec!["North".into()]), + GridUpdate::RowFrame(AxisFrame::flat(["North"])), ))]), ServerFrame::Rejected(Rejection::LayoutMoved), ] { diff --git a/crates/improvise-protocol/src/lib.rs b/crates/improvise-protocol/src/lib.rs index c33ca2e7..a8459ca0 100644 --- a/crates/improvise-protocol/src/lib.rs +++ b/crates/improvise-protocol/src/lib.rs @@ -42,10 +42,11 @@ pub use key::{KeyCode, KeyEvent, KeyModifiers}; pub use keymap::{Binding, KeyPattern, Keymap, Keymaps, ModeKey}; pub use reduce::{Command, HelpPage, ModelCommand, reduce_view}; pub use render::{ - CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, FormulaCache, - GridCache, GridExtent, GridUpdate, Projection, RecordCell, Snapshot, TileBarCache, TileEntry, - ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardDateField, WizardFieldKind, - WizardProposal, WizardStepKind, + AxisFrame, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, + FormulaCache, FrameEntry, FrameGroup, GridCache, GridExtent, GridUpdate, MAX_ROW_HEADER_W, + MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache, TileEntry, ViewListCache, + ViewSummary, WidgetCache, WizardCache, WizardDateField, WizardFieldKind, WizardProposal, + WizardStepKind, }; pub use viewmodel::{CatTreeEntry, flatten_cat_tree}; // The surrogates themselves are `improvise-core`'s, because the index that diff --git a/crates/improvise-protocol/src/reduce.rs b/crates/improvise-protocol/src/reduce.rs index 717ef55a..5cc80b4c 100644 --- a/crates/improvise-protocol/src/reduce.rs +++ b/crates/improvise-protocol/src/reduce.rs @@ -313,7 +313,7 @@ pub fn reduce_view(view: &mut ViewState, cmd: &Command) { mod tests { use super::*; use crate::render::{CategoryTreeUpdate, CellDisplay, GridUpdate, TileEntry, ViewSummary}; - use crate::{Panel, ViewCursor}; + use crate::{AxisFrame, Panel, ViewCursor}; use improvise_core::model::cell::CellKey; use improvise_core::model::record::RecordHash; use improvise_core::view::Axis; @@ -449,7 +449,9 @@ mod tests { Command::ToggleCatExpand("Region".into()), Command::SetSearchQuery("north".into()), Command::HelpPage(HelpPage::Next), - Command::Project(Projection::Grid(GridUpdate::RowLabels(vec!["r".into()]))), + Command::Project(Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([ + "r", + ])))), ]; let a = fold(&stream); let b = fold(&stream); @@ -509,7 +511,9 @@ mod tests { ((0, 1), CellDisplay::number("1,234.50")), ((2, 3), CellDisplay::error("#div/0")), ]))), - Command::Project(Projection::Grid(GridUpdate::ColLabels(vec!["c0".into()]))), + Command::Project(Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([ + "c0", + ])))), Command::Project(Projection::Grid(GridUpdate::ColWidths(vec![8, 12]))), Command::Project(Projection::Grid(GridUpdate::Invalidated)), Command::Project(Projection::CategoryTree(CategoryTreeUpdate::Items { diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index 7e8e8633..ae260ce0 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -15,6 +15,7 @@ //! projections, so there is no second, aggregate shape to keep in step. use serde::{Deserialize, Serialize}; +use unicode_width::UnicodeWidthStr; use improvise_core::model::record::{AggregateHash, RecordHash}; use improvise_core::view::Axis; @@ -173,6 +174,227 @@ impl CellDisplay { } } +/// Minimum and maximum width of **one level** of a row header, in display +/// cells. Moved here from `improvise-command::ui::viewport` (improvise-1125) +/// so the rule and the numbers it clamps to have one home. +pub const MIN_ROW_HEADER_W: u16 = 4; +pub const MAX_ROW_HEADER_W: u16 = 24; + +/// A run of positions on one axis that share a group. +/// +/// `collapsed` is here rather than left to the client because it is a fact +/// about the *view*, which only the server holds: a client re-deriving it +/// would have to be told the collapsed set, which is one more thing to keep +/// in step for no gain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrameGroup { + /// The category the group belongs to. A client echoes it back in + /// `ToggleGroup`; it never has to work out which category a label came + /// from (which slash-joined labels made impossible). + pub cat: String, + pub name: String, + pub collapsed: bool, +} + +/// One entry on a grid axis, in visual order. +/// +/// The two are interleaved exactly as `GridLayout::row_items` interleaves +/// them: a group header, then the items inside it. On the row axis the TUI +/// paints a `Group` as its own row; on the column axis it paints the runs as +/// spans above the headers. That difference is *painting*, and it is the +/// only thing that differs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum FrameEntry { + Group(FrameGroup), + /// One label per header level, outermost first. Data indices — the + /// `row`/`col` of every other field on [`GridCache`] — count only these. + Item(Vec), +} + +/// One axis of the grid's *frame*: the header levels, the label tuples and +/// the groups (improvise-anr9/s1xw). +/// +/// This replaced a `Vec` of labels that `GridLayout::row_label` +/// built by **slash-joining a multi-level tuple** — the exact rendering +/// `two_row_categories_produce_cross_product_labels` asserts must not +/// appear. The protocol carried a form of the labels the TUI itself +/// considered wrong, so the browser drew `Food/Alice` where the TUI drew two +/// header columns, with no groups and no collapse indicators (gotcha 44). +/// +/// It is one type rather than a `labels` plus a `levels` beside it, and that +/// is the whole point: **two answers to "what are the labels" is the +/// disease** this projection architecture exists to cure (improvise-edp). +/// A renderer asks this type and there is nothing else to ask. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AxisFrame { + /// The categories on this axis, outermost first. Empty when the axis + /// carries no category at all, which is a real layout (one nameless + /// row or column) and not a gap — hence [`AxisFrame::levels`]. + pub cats: Vec, + pub entries: Vec, +} + +impl AxisFrame { + /// A single-level axis with no groups — what a flat list of labels used + /// to be. `cats` is left empty, so this is not a way to smuggle a + /// multi-level axis in flattened. + pub fn flat>(labels: impl IntoIterator) -> Self { + Self { + cats: Vec::new(), + entries: labels + .into_iter() + .map(|l| FrameEntry::Item(vec![l.into()])) + .collect(), + } + } + + /// The data-item tuples, in order. Group headers are skipped: a data + /// index counts items only, exactly as `GridLayout` does. + pub fn items(&self) -> impl Iterator> { + self.entries.iter().filter_map(|e| match e { + FrameEntry::Item(v) => Some(v), + FrameEntry::Group(_) => None, + }) + } + + /// The innermost label of each data item. + /// + /// **Not "the labels".** A multi-level axis draws one label per level, + /// and a renderer wants [`AxisFrame::label`] with + /// [`AxisFrame::shows_level`]. This is the single most specific name a + /// position has — what a test or a one-line summary calls a row by — and + /// a renderer reaching for it on a multi-level axis is drawing the wrong + /// header, not a shorter one. + pub fn leaf_labels(&self) -> Vec { + self.items() + .map(|v| v.last().cloned().unwrap_or_default()) + .collect() + } + + /// How many data items this frame names — the number of rows or columns + /// the cells are indexed by. + pub fn len(&self) -> usize { + self.items().count() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn item(&self, idx: usize) -> Option<&Vec> { + self.items().nth(idx) + } + + /// How many header levels this axis draws. **At least one**: an axis + /// with no categories still draws a single (empty) header line, which is + /// what the grid has always done. + pub fn levels(&self) -> usize { + self.cats.len().max(1) + } + + /// The label of the data item at `idx` on level `level`, or `""` where + /// the tuple is shorter (an axis with no categories has an empty tuple). + pub fn label(&self, idx: usize, level: usize) -> &str { + self.item(idx) + .and_then(|v| v.get(level)) + .map(String::as_str) + .unwrap_or_default() + } + + /// The label tuple of `idx` down to and including `level`, clamped to + /// the tuple's own length so a short tuple cannot panic. + fn prefix(&self, idx: usize, level: usize) -> Option<&[String]> { + self.item(idx).map(|v| &v[..(level + 1).min(v.len())]) + } + + /// Should the header at `(idx, level)` be drawn, or blanked because it + /// repeats the entry above it? + /// + /// `first_rendered` is the first data index actually on screen: that + /// entry always shows its full tuple, or a scrolled viewport loses the + /// group context at its top edge (improvise-jjx). + pub fn shows_level(&self, idx: usize, first_rendered: usize, level: usize) -> bool { + idx == first_rendered || idx == 0 || self.prefix(idx, level) != self.prefix(idx - 1, level) + } + + /// Whether two data items sit under the same ancestor down to `level` — + /// what the TUI underlines the selected column's ancestry with. + pub fn same_prefix(&self, a: usize, b: usize, level: usize) -> bool { + match (self.prefix(a, level), self.prefix(b, level)) { + (Some(x), Some(y)) => x == y, + _ => false, + } + } + + /// The group the data item at `idx` sits in: the nearest [`FrameEntry::Group`] + /// before it. `None` for an item outside any group. + /// + /// This is `GridLayout::col_group_for` / `row_group_for`, answered off + /// the frame instead of off the model — which is what lets a client that + /// holds no model draw column group spans and a collapse indicator. + pub fn group_of(&self, idx: usize) -> Option<&FrameGroup> { + let visual = self.visual_of(idx)?; + self.entries[..visual].iter().rev().find_map(|e| match e { + FrameEntry::Group(g) => Some(g), + FrameEntry::Item(_) => None, + }) + } + + /// Position of the data item `idx` within `entries`, group headers + /// included. + fn visual_of(&self, idx: usize) -> Option { + self.entries + .iter() + .enumerate() + .filter(|(_, e)| matches!(e, FrameEntry::Item(_))) + .map(|(vi, _)| vi) + .nth(idx) + } + + /// The entries a renderer walks, starting at the data item `idx`. + /// + /// A frame that opens mid-group carries the group header that owns its + /// first item, so [`AxisFrame::group_of`] can answer for it — but that + /// header is *above* the first drawn row and must not be painted as one. + /// Dropping it here is the same cut `GridLayout::data_row_to_visual` has + /// always made. + /// + /// The argument exists because the two front ends scroll differently and + /// not because the rule does: a browser's frame is already windowed and + /// passes `0`, while the TUI's covers the whole grid and passes its row + /// offset (gotcha 44). + pub fn body_from(&self, idx: usize) -> &[FrameEntry] { + let start = self.visual_of(idx).unwrap_or(self.entries.len()); + &self.entries[start..] + } + + /// The width of each header level, in display cells: the widest label on + /// that level plus a gap, clamped. + /// + /// The one home for this rule (improvise-1125). It lived twice before — + /// inline in `src/ui/grid.rs::render_grid` and again in + /// `improvise-command::ui::viewport::compute_row_header_width` — and a + /// browser sizing its own row header would have been a third. It is + /// derived from the frame rather than projected as a field because the + /// frame is the whole input: nothing about the model is needed, so a + /// shared function cannot fall out of step with the data the way a + /// separately-sent field could. + pub fn header_widths(&self) -> Vec { + let items: Vec<&Vec> = self.items().collect(); + (0..self.levels()) + .map(|level| { + let widest = items + .iter() + .filter_map(|v| v.get(level)) + .map(|s| UnicodeWidthStr::width(s.as_str()) as u16) + .max() + .unwrap_or(0); + (widest + 1).clamp(MIN_ROW_HEADER_W, MAX_ROW_HEADER_W) + }) + .collect() + } +} + /// The grid widget's copy of the visible cells (improvise-n10). /// /// Indexed by position within the *viewport*, not by `CellKey`: the client @@ -180,14 +402,16 @@ impl CellDisplay { /// what a row means as long as it says so. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct GridCache { - pub row_labels: Vec, - pub col_labels: Vec, + /// The row axis's frame: levels, label tuples and groups. **Not** a list + /// of strings — see [`AxisFrame`]. + pub row_frame: AxisFrame, + pub col_frame: AxisFrame, pub col_widths: Vec, - /// Row-major, `row_labels.len()` rows of `col_labels.len()` cells. + /// Row-major, `row_frame.len()` rows of `col_frame.len()` cells. pub cells: Vec>, /// The column totals strip, one entry per column, drawn under the grid. /// - /// Framed by `col_labels` exactly as `cells` and `col_widths` are, and + /// Framed by `col_frame` exactly as `cells` and `col_widths` are, and /// re-fitted by the same rule (gotcha 33c) — a renderer zips it with the /// columns. /// @@ -209,16 +433,16 @@ pub struct GridCache { /// window (improvise-bpq2). /// /// The one fact a client needs to move its own cursor, and the reason - /// it is here rather than derived: `row_labels.len()` is what this + /// it is here rather than derived: `row_frame.len()` is what this /// client was *sent*, so a client holding only its window cannot tell /// "the last visible row" from "the last row" and cannot clamp. A /// cursor clamped against the window stops at the bottom of the screen. /// - /// It is deliberately **not** framed by the labels, unlike `cells`, - /// `col_widths` and `totals`: it is the size of the thing the labels are + /// It is deliberately **not** framed by the frames, unlike `cells`, + /// `col_widths` and `totals`: it is the size of the thing the frames are /// a window *onto*, so `fit_to_labels` must leave it alone. For a client /// whose window covers the whole grid — the TUI, which builds its cache - /// at offsets zero (gotcha 44) — it equals the label counts, and that + /// at offsets zero (gotcha 44) — it equals the frame counts, and that /// coincidence is exactly what makes a derived version look correct /// there and fail in a browser. pub extent: GridExtent, @@ -227,7 +451,8 @@ pub struct GridCache { /// The size of the whole grid: what a cursor clamps against. /// /// A named pair rather than two `usize` on [`GridCache`], because beside -/// `row_labels`/`col_labels` two bare counts read as "how many labels" — +/// `row_frame`/`col_frame` two bare counts read as "how many rows are in +/// the frame" — /// which is the confusion this field exists to remove. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct GridExtent { @@ -262,8 +487,8 @@ impl GridCache { .collect(); vec![ GridUpdate::Extent(self.extent), - GridUpdate::RowLabels(self.row_labels.clone()), - GridUpdate::ColLabels(self.col_labels.clone()), + GridUpdate::RowFrame(self.row_frame.clone()), + GridUpdate::ColFrame(self.col_frame.clone()), GridUpdate::ColWidths(self.col_widths.clone()), GridUpdate::PageCoords(self.page_coords.clone()), GridUpdate::Totals(self.totals.clone()), @@ -271,26 +496,30 @@ impl GridCache { ] } - /// Resize to match the current labels, preserving what still fits. - /// The labels frame the cells *and the widths*, so every update that - /// touches any of the three re-fits the grid — both label arms and - /// `ColWidths`. Fitting on the label arms alone was the asymmetry that + /// Resize to match the current frames, preserving what still fits. + /// The frames frame the cells *and the widths*, so every update that + /// touches any of the three re-fits the grid — both frame arms and + /// `ColWidths`. Fitting on the frame arms alone was the asymmetry that /// let a widths update put the mismatch back. /// /// The widths are framed by the columns too: a column may arrive one - /// update ahead of its width, so a new column is drawn at its label's - /// own width until `ColWidths` catches up. Letting `col_widths` fall out - /// of step with `col_labels` is not a cache that is merely stale — it is - /// one a renderer zipping the two indexes off the end of. + /// update ahead of its width, so a new column is drawn at its own + /// widest level label until `ColWidths` catches up. Letting `col_widths` + /// fall out of step with `col_frame` is not a cache that is merely stale + /// — it is one a renderer zipping the two indexes off the end of. fn fit_to_labels(&mut self) { - let cols = self.col_labels.len(); - self.cells.resize_with(self.row_labels.len(), Vec::new); + let cols = self.col_frame.len(); + self.cells.resize_with(self.row_frame.len(), Vec::new); for row in &mut self.cells { row.resize_with(cols, CellDisplay::default); } self.col_widths.truncate(cols); for col in self.col_widths.len()..cols { - let width = self.col_labels[col].chars().count(); + let width = self + .col_frame + .item(col) + .and_then(|levels| levels.iter().map(|s| s.chars().count()).max()) + .unwrap_or(0); self.col_widths.push(width.min(u16::MAX as usize) as u16); } // The totals strip is framed by the columns on the same terms, with @@ -303,15 +532,22 @@ impl GridCache { } } - /// Blank every cell framed by a label that is not the one that was + /// Blank every cell framed by a label tuple that is not the one that was /// there before, since the value under it answered the old label. - fn invalidate_relabelled(&mut self, old: &[String], axis: LabelAxis) { + /// + /// The comparison is on the **item tuples**, not on the entries: a group + /// header appearing or collapsing changes the frame's shape without + /// renaming any position that survives it, and blanking on that would + /// throw away cells the very next `Cells` update would have to resend. + fn invalidate_relabelled(&mut self, old: &AxisFrame, axis: LabelAxis) { let new = match axis { - LabelAxis::Row => &self.row_labels, - LabelAxis::Col => &self.col_labels, + LabelAxis::Row => &self.row_frame, + LabelAxis::Col => &self.col_frame, }; - let stale: Vec = (0..new.len()) - .filter(|&i| old.get(i) != Some(&new[i])) + let old_items: Vec<&Vec> = old.items().collect(); + let new_items: Vec<&Vec> = new.items().collect(); + let stale: Vec = (0..new_items.len()) + .filter(|&i| old_items.get(i) != Some(&new_items[i])) .collect(); for i in stale { match axis { @@ -348,8 +584,11 @@ enum LabelAxis { pub enum GridUpdate { /// Display values for cells, by viewport position. Cells(Vec<((usize, usize), CellDisplay)>), - RowLabels(Vec), - ColLabels(Vec), + /// The row axis's whole frame — levels, label tuples and groups + /// (improvise-anr9). It replaced a `Vec` of slash-joined labels; + /// see [`AxisFrame`]. + RowFrame(AxisFrame), + ColFrame(AxisFrame), ColWidths(Vec), /// The column totals strip. An empty vector means the grid has no /// totals to draw, which is a fact and not a gap. @@ -389,13 +628,13 @@ impl WidgetCache for GridCache { } } } - GridUpdate::RowLabels(labels) => { - let old = std::mem::replace(&mut self.row_labels, labels.clone()); + GridUpdate::RowFrame(frame) => { + let old = std::mem::replace(&mut self.row_frame, frame.clone()); self.fit_to_labels(); self.invalidate_relabelled(&old, LabelAxis::Row); } - GridUpdate::ColLabels(labels) => { - let old = std::mem::replace(&mut self.col_labels, labels.clone()); + GridUpdate::ColFrame(frame) => { + let old = std::mem::replace(&mut self.col_frame, frame.clone()); self.fit_to_labels(); self.invalidate_relabelled(&old, LabelAxis::Col); } @@ -748,8 +987,8 @@ mod tests { fn framed_grid() -> GridCache { GridCache::folded(&[ - GridUpdate::RowLabels(vec!["r0".into(), "r1".into()]), - GridUpdate::ColLabels(vec!["c0".into(), "c1".into(), "c2".into()]), + GridUpdate::RowFrame(AxisFrame::flat(["r0", "r1"])), + GridUpdate::ColFrame(AxisFrame::flat(["c0", "c1", "c2"])), ]) } @@ -790,15 +1029,18 @@ mod tests { grid.apply(&GridUpdate::Cells(vec![((0, 0), CellDisplay::number("7"))])); grid.apply(&GridUpdate::Invalidated); assert_eq!(grid.cell(0, 0).unwrap().text, ""); - assert_eq!(grid.row_labels, vec!["r0".to_string(), "r1".to_string()]); - assert_eq!(grid.col_labels.len(), 3); + assert_eq!( + grid.row_frame.leaf_labels(), + vec!["r0".to_string(), "r1".to_string()] + ); + assert_eq!(grid.col_frame.len(), 3); } #[test] fn shrinking_the_labels_drops_the_cells_that_no_longer_fit() { let mut grid = framed_grid(); grid.apply(&GridUpdate::Cells(vec![((1, 2), CellDisplay::number("9"))])); - grid.apply(&GridUpdate::RowLabels(vec!["r0".into()])); + grid.apply(&GridUpdate::RowFrame(AxisFrame::flat(["r0"]))); assert_eq!(grid.cells.len(), 1); assert!(grid.cell(1, 2).is_none()); } @@ -817,7 +1059,7 @@ mod tests { CellDisplay::number("4"), CellDisplay::number("5"), ])); - assert_eq!(grid.totals.len(), grid.col_labels.len()); + assert_eq!(grid.totals.len(), grid.col_frame.len()); } /// An *empty* strip says "this grid has no totals" — records mode, or an @@ -828,7 +1070,7 @@ mod tests { fn an_empty_totals_strip_is_not_padded_into_blanks() { let mut grid = framed_grid(); assert!(grid.totals.is_empty()); - grid.apply(&GridUpdate::ColLabels(vec!["c0".into(), "c1".into()])); + grid.apply(&GridUpdate::ColFrame(AxisFrame::flat(["c0", "c1"]))); assert!(grid.totals.is_empty(), "no totals is not a row of blanks"); } @@ -861,11 +1103,120 @@ mod tests { "Payer".into(), "Alice".into(), )])); - grid.apply(&GridUpdate::RowLabels(vec![])); - grid.apply(&GridUpdate::ColLabels(vec![])); + grid.apply(&GridUpdate::RowFrame(AxisFrame::default())); + grid.apply(&GridUpdate::ColFrame(AxisFrame::default())); assert_eq!(grid.page_coords, vec![("Payer".into(), "Alice".into())]); } + // ── AxisFrame: the frame rule both front ends now share ───────────── + + /// A two-level axis: [A,x], [A,y], [B,z], with `B` in a collapsed group. + fn two_level_frame() -> AxisFrame { + AxisFrame { + cats: vec!["Type".into(), "Who".into()], + entries: vec![ + FrameEntry::Item(vec!["A".into(), "x".into()]), + FrameEntry::Item(vec!["A".into(), "y".into()]), + FrameEntry::Group(FrameGroup { + cat: "Type".into(), + name: "Late".into(), + collapsed: true, + }), + FrameEntry::Item(vec!["B".into(), "z".into()]), + ], + } + } + + /// Bug (improvise-jjx), moved here with the rule (improvise-anr9): + /// suppression compared against the entry at `idx - 1` even when that + /// entry was scrolled off-screen, so scrolling blanked the group label + /// at the top of the viewport. The first RENDERED entry always shows its + /// full tuple. + #[test] + fn the_first_rendered_entry_always_shows_its_level() { + let frame = two_level_frame(); + assert!( + frame.shows_level(1, 1, 0), + "index 1 is the first on screen; its level-0 prefix matches the \ + off-screen index 0 and must still be drawn" + ); + } + + #[test] + fn a_repeated_prefix_below_the_first_rendered_entry_is_suppressed() { + let frame = two_level_frame(); + assert!(!frame.shows_level(1, 0, 0), "[A,y] repeats [A,x]'s 'A'"); + assert!(frame.shows_level(1, 0, 1), "its leaf 'y' differs from 'x'"); + assert!(frame.shows_level(2, 0, 0), "'B' differs from 'A'"); + assert!(frame.shows_level(2, 1, 0), "scrolled, and still differs"); + } + + /// Group headers do not consume a data index, and a data index resolves + /// to the group whose header precedes it — which is what makes a column + /// group span drawable by a client that holds no `GridLayout`. + #[test] + fn a_data_index_skips_group_headers_and_still_names_its_group() { + let frame = two_level_frame(); + assert_eq!(frame.len(), 3, "three data items, one group header"); + assert_eq!(frame.item(2), Some(&vec!["B".to_string(), "z".to_string()])); + assert!(frame.group_of(0).is_none(), "[A,x] is in no group"); + let group = frame.group_of(2).expect("[B,z] sits under Late"); + assert_eq!((group.cat.as_str(), group.name.as_str()), ("Type", "Late")); + assert!(group.collapsed, "the collapse state travels with the group"); + } + + /// `body_from` starts at a data item, never at the header above it: a + /// window opening mid-group must be able to *name* its group without + /// painting that group's header as a row inside the window. + #[test] + fn body_from_starts_at_the_item_not_at_the_header_above_it() { + let frame = two_level_frame(); + assert!( + matches!(frame.body_from(2), [FrameEntry::Item(_)]), + "the trailing group header is above item 2, not inside the body" + ); + assert_eq!(frame.body_from(0).len(), 4, "everything, headers included"); + } + + /// The one width rule (improvise-1125): per level, the widest label plus + /// a gap, clamped. Measured in display cells, so a wide character counts + /// two — a `chars().count()` would draw the header one cell short. + #[test] + fn header_widths_are_per_level_and_measured_in_display_cells() { + let frame = AxisFrame { + cats: vec!["A".into(), "B".into()], + entries: vec![FrameEntry::Item(vec!["Clothing".into(), "日本".into()])], + }; + assert_eq!( + frame.header_widths(), + vec![9, 5], + "level 0 is 'Clothing' (8) + 1; level 1 is 4 display cells + 1. \ + A `chars().count()` would make level 1 two cells wide and clamp \ + it to the floor of {MIN_ROW_HEADER_W}, drawing the header short" + ); + assert_eq!( + AxisFrame::flat(["ab"]).header_widths(), + vec![MIN_ROW_HEADER_W], + "and the floor is real: 'ab' + 1 is under it" + ); + let wide = AxisFrame::flat(["x".repeat(100)]); + assert_eq!(wide.header_widths(), vec![MAX_ROW_HEADER_W]); + } + + /// An axis with no categories still draws one header line, and its item + /// tuples are empty — reading a level off one must answer `""` rather + /// than panic. This is the shape a view with nothing on Row produces. + #[test] + fn an_axis_with_no_categories_still_has_one_level() { + let frame = AxisFrame { + cats: Vec::new(), + entries: vec![FrameEntry::Item(Vec::new())], + }; + assert_eq!(frame.levels(), 1); + assert_eq!(frame.label(0, 0), ""); + assert!(frame.shows_level(0, 0, 0)); + } + #[test] fn item_updates_touch_only_the_named_category() { let mut tree = CategoryTreeCache::folded(&[ @@ -954,15 +1305,15 @@ mod tests { #[test] fn a_relabelled_column_does_not_keep_the_old_columns_value() { let mut grid = GridCache::folded(&[ - GridUpdate::RowLabels(vec!["r0".into()]), - GridUpdate::ColLabels(vec!["A".into(), "B".into(), "C".into()]), + GridUpdate::RowFrame(AxisFrame::flat(["r0"])), + GridUpdate::ColFrame(AxisFrame::flat(["A", "B", "C"])), GridUpdate::Cells(vec![ ((0, 0), CellDisplay::number("1")), ((0, 1), CellDisplay::number("2")), ((0, 2), CellDisplay::number("3")), ]), ]); - grid.apply(&GridUpdate::ColLabels(vec!["A".into(), "C".into()])); + grid.apply(&GridUpdate::ColFrame(AxisFrame::flat(["A", "C"]))); assert_eq!( grid.cell(0, 0).unwrap().text, @@ -982,14 +1333,14 @@ mod tests { #[test] fn a_label_change_blanks_only_the_positions_it_reframes() { let mut grid = GridCache::folded(&[ - GridUpdate::RowLabels(vec!["r0".into(), "r1".into()]), - GridUpdate::ColLabels(vec!["c0".into()]), + GridUpdate::RowFrame(AxisFrame::flat(["r0", "r1"])), + GridUpdate::ColFrame(AxisFrame::flat(["c0"])), GridUpdate::Cells(vec![ ((0, 0), CellDisplay::number("1")), ((1, 0), CellDisplay::number("2")), ]), ]); - grid.apply(&GridUpdate::RowLabels(vec!["r0".into(), "rX".into()])); + grid.apply(&GridUpdate::RowFrame(AxisFrame::flat(["r0", "rX"]))); assert_eq!(grid.cell(0, 0).unwrap().text, "1"); assert_eq!(grid.cell(1, 0).unwrap().text, ""); } @@ -1000,18 +1351,18 @@ mod tests { #[test] fn col_widths_always_span_the_column_labels() { let mut grid = GridCache::folded(&[ - GridUpdate::ColLabels(vec!["A".into()]), + GridUpdate::ColFrame(AxisFrame::flat(["A"])), GridUpdate::ColWidths(vec![8]), ]); - grid.apply(&GridUpdate::ColLabels(vec!["A".into(), "Bravo".into()])); - assert_eq!(grid.col_widths.len(), grid.col_labels.len()); + grid.apply(&GridUpdate::ColFrame(AxisFrame::flat(["A", "Bravo"]))); + assert_eq!(grid.col_widths.len(), grid.col_frame.len()); assert_eq!(grid.col_widths[0], 8, "the width already sent is kept"); assert_eq!( grid.col_widths[1], 5, "a column with no width yet is drawn at its label's width" ); - grid.apply(&GridUpdate::ColLabels(vec!["A".into()])); + grid.apply(&GridUpdate::ColFrame(AxisFrame::flat(["A"]))); assert_eq!(grid.col_widths, vec![8]); } @@ -1027,13 +1378,12 @@ mod tests { /// overtakes its frame — the same rule `Cells` is already held to. #[test] fn a_col_widths_update_is_fitted_to_the_labels_it_is_framed_by() { - let mut grid = - GridCache::folded(&[GridUpdate::ColLabels(vec!["A".into(), "Bravo".into()])]); + let mut grid = GridCache::folded(&[GridUpdate::ColFrame(AxisFrame::flat(["A", "Bravo"]))]); grid.apply(&GridUpdate::ColWidths(vec![8])); assert_eq!( grid.col_widths.len(), - grid.col_labels.len(), + grid.col_frame.len(), "a short widths update must not leave a renderer zipping off the end" ); assert_eq!(grid.col_widths[0], 8, "the width that was sent is taken"); @@ -1060,8 +1410,8 @@ mod tests { #[test] fn a_cell_update_that_overtakes_its_labels_is_lost() { let cells = GridUpdate::Cells(vec![((0, 0), CellDisplay::number("42"))]); - let rows = GridUpdate::RowLabels(vec!["r0".into()]); - let cols = GridUpdate::ColLabels(vec!["c0".into()]); + let rows = GridUpdate::RowFrame(AxisFrame::flat(["r0"])); + let cols = GridUpdate::ColFrame(AxisFrame::flat(["c0"])); let in_order = GridCache::folded(&[rows.clone(), cols.clone(), cells.clone()]); let raced = GridCache::folded(&[cells, rows, cols]); @@ -1088,8 +1438,8 @@ mod tests { fn a_compacted_seed_folds_to_the_same_cache_as_the_whole_history() { let history = [ GridUpdate::Extent(GridExtent { rows: 2, cols: 2 }), - GridUpdate::RowLabels(vec!["r0".into(), "r1".into()]), - GridUpdate::ColLabels(vec!["c0".into(), "c1".into()]), + GridUpdate::RowFrame(AxisFrame::flat(["r0", "r1"])), + GridUpdate::ColFrame(AxisFrame::flat(["c0", "c1"])), GridUpdate::ColWidths(vec![8, 12]), GridUpdate::Cells(vec![((0, 0), CellDisplay::number("1"))]), GridUpdate::Cells(vec![ @@ -1097,7 +1447,7 @@ mod tests { ((1, 1), CellDisplay::error("#div/0")), ]), GridUpdate::Extent(GridExtent { rows: 3, cols: 2 }), - GridUpdate::RowLabels(vec!["r0".into(), "r1".into(), "r2".into()]), + GridUpdate::RowFrame(AxisFrame::flat(["r0", "r1", "r2"])), GridUpdate::Cells(vec![((2, 0), CellDisplay::text("late"))]), ]; let live = GridCache::folded(&history); @@ -1128,8 +1478,8 @@ mod tests { #[test] fn a_snapshot_seeds_every_widget_from_its_compacted_state() { let history = [ - Projection::Grid(GridUpdate::RowLabels(vec!["r0".into()])), - Projection::Grid(GridUpdate::ColLabels(vec!["c0".into()])), + Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["r0"]))), + Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["c0"]))), Projection::Formulas(vec!["Profit = Revenue - Cost".into()]), Projection::Grid(GridUpdate::Cells(vec![((0, 0), CellDisplay::number("5"))])), Projection::Formulas(vec!["Tax = Revenue * 0.1".into()]), @@ -1181,8 +1531,8 @@ mod tests { ((2, 3), CellDisplay::error("#div/0")), ((4, 5), CellDisplay::text("pending")), ])), - Projection::Grid(GridUpdate::RowLabels(vec!["r".into()])), - Projection::Grid(GridUpdate::ColLabels(vec!["c".into()])), + Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["r"]))), + Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["c"]))), Projection::Grid(GridUpdate::ColWidths(vec![8, 12])), Projection::Grid(GridUpdate::Invalidated), Projection::CategoryTree(CategoryTreeUpdate::Categories(vec![cat( @@ -1235,7 +1585,7 @@ mod tests { let snapshot = Snapshot { view, seed: vec![ - Projection::Grid(GridUpdate::RowLabels(vec!["r0".into()])), + Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["r0"]))), Projection::Grid(GridUpdate::ColWidths(vec![8])), Projection::Grid(GridUpdate::Cells(vec![( (0, 0), diff --git a/crates/improvise-wasm-client/src/keys.rs b/crates/improvise-wasm-client/src/keys.rs index 371ab885..bc6693bc 100644 --- a/crates/improvise-wasm-client/src/keys.rs +++ b/crates/improvise-wasm-client/src/keys.rs @@ -59,9 +59,16 @@ //! which is `yank`, `wq` and `toggle-col-group-under-cursor`. Arming a //! prefix whose payload is unreachable is worse than not arming it. //! - **Anything naming a row, column, item or group** — `hide-selected-row-item`, -//! `toggle-group-under-cursor`, `delete-formula-at-cursor`. Not projected, -//! and `GridCache::row_labels` slash-joins a multi-level tuple (gotcha 44), -//! so there is nothing to take one apart with. +//! `toggle-group-under-cursor`, `delete-formula-at-cursor`. The first two +//! are **no longer blocked** and are simply not built here yet +//! (improvise-e24v): since improvise-anr9 `GridCache` carries an +//! `AxisFrame` per axis, so `cats[0]` + `label(idx, 0)` is exactly what +//! `HideSelectedRowItem` reads off `GridLayout`, and `group_of(idx)` is +//! exactly `ToggleGroup`'s `(category, group)`. The one thing to get right +//! is that the frame is **windowed** and the cursor is absolute, so a +//! client must reconcile the scroll offset first, as `render.rs` does for +//! the selection. `delete-formula-at-cursor` stays here: it names a +//! position in the formula panel, not in the grid. //! - **Anything touching storage or an arbitrary command line.** The host's //! by definition. //! - **The records-mode commit.** A wide-records row is one coordinate tuple @@ -720,7 +727,9 @@ fn seed_at(state: &ClientState, row: usize, col: usize) -> String { #[cfg(test)] mod tests { use super::*; - use improvise_protocol::{CellDisplay, GridUpdate, Projection, RecordCell, Snapshot}; + use improvise_protocol::{ + AxisFrame, CellDisplay, GridUpdate, Projection, RecordCell, Snapshot, + }; fn ev(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) @@ -1208,8 +1217,8 @@ mod tests { client.bootstrap(Snapshot { view: ViewState::default(), seed: vec![ - Projection::Grid(GridUpdate::RowLabels(vec!["North".into(), "South".into()])), - Projection::Grid(GridUpdate::ColLabels(vec!["Revenue".into()])), + Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North", "South"]))), + Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))), Projection::Grid(GridUpdate::Cells(vec![ ((0, 0), first), ((1, 0), CellDisplay::number("900")), @@ -1359,8 +1368,8 @@ mod tests { client.bootstrap(Snapshot { view: ViewState::default(), seed: vec![ - Projection::Grid(GridUpdate::RowLabels(vec!["North".into()])), - Projection::Grid(GridUpdate::ColLabels(vec!["Revenue".into()])), + Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))), + Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))), Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)])), Projection::Grid(GridUpdate::Extent(GridExtent { rows: 1, cols: 1 })), Projection::Basis(published()), diff --git a/crates/improvise-wasm-client/src/lib.rs b/crates/improvise-wasm-client/src/lib.rs index 7c81cb05..85c8a6e3 100644 --- a/crates/improvise-wasm-client/src/lib.rs +++ b/crates/improvise-wasm-client/src/lib.rs @@ -178,7 +178,7 @@ impl Client { /// The drop-in successor to [`Client::viewport`]: same frame, same one /// call per handshake and per resize, and it keeps the window so cursor /// keys can be resolved here instead of vanishing upstream. The window - /// is not derivable from anything the host sends — `row_labels.len()` is + /// is not derivable from anything the host sends — `row_frame.len()` is /// the window clipped to the grid — so this is the only place a client /// can learn it, which is why recording it belongs on the call that /// announces it rather than in a second call a page could forget. @@ -309,7 +309,9 @@ impl Client { #[cfg(test)] mod tests { use super::*; - use improvise_protocol::{AppMode, Command, GridUpdate, Projection, ServerFrame, ViewState}; + use improvise_protocol::{ + AppMode, AxisFrame, Command, GridUpdate, Projection, ServerFrame, ViewState, + }; // ── composing a write (ADR 002) ───────────────────────────────────── @@ -328,8 +330,8 @@ mod tests { client.bootstrap(Snapshot { view: ViewState::default(), seed: vec![ - Projection::Grid(GridUpdate::RowLabels(vec!["North".into()])), - Projection::Grid(GridUpdate::ColLabels(vec!["Revenue".into()])), + Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))), + Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))), Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)])), Projection::Basis(published_basis()), ], @@ -389,8 +391,8 @@ mod tests { client.bootstrap(Snapshot { view: ViewState::default(), seed: vec![ - Projection::Grid(GridUpdate::RowLabels(vec!["North".into()])), - Projection::Grid(GridUpdate::ColLabels(vec!["Revenue".into()])), + Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))), + Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))), Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)])), ], }); @@ -460,9 +462,9 @@ mod tests { mode: AppMode::category_add(), ..ViewState::default() }, - seed: vec![Projection::Grid(GridUpdate::RowLabels(vec![ - "North".into(), - ]))], + seed: vec![Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([ + "North", + ])))], }); assert_eq!( client @@ -483,7 +485,10 @@ mod tests { let rendered: serde_json::Value = serde_json::from_str(&client.state_json().unwrap()).unwrap(); - assert_eq!(rendered["grid"]["row_labels"][0], "North"); + assert_eq!( + rendered["grid"]["row_frame"]["entries"][0]["Item"][0], + "North" + ); assert_eq!(rendered["view"]["buffers"]["category"], "R"); assert_eq!(rendered["revision"], client.revision()); } diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 6a564f43..ce421cd8 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -28,13 +28,22 @@ //! 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::{CellDisplay, CellId}; +use improvise_protocol::{AxisFrame, CellDisplay, CellId, FrameEntry}; use serde::Serialize; use crate::{Client, ClientState}; @@ -50,7 +59,16 @@ pub struct RenderModel { pub status: String, /// The document's active view, as this client knows it (gotcha 29). pub view_name: String, - pub col_labels: Vec, + /// 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>, + /// 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, + /// How many row-header levels every [`RenderRow::Data`] carries. + pub row_levels: usize, pub rows: Vec, /// The category panel, flattened by the one flattening. pub categories: Vec, @@ -78,11 +96,59 @@ pub struct RenderModel { pub editing: Option, } -/// A grid row: its header label and its cells. +/// 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, + cells: Vec, + }, +} + +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 RenderRow { +pub struct RenderColGroup { pub label: String, - pub cells: Vec, + pub collapsed: bool, + pub span: usize, } /// One cell, with every question already answered. @@ -140,35 +206,56 @@ impl ClientState { 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_labels: self.grid.col_labels.clone(), - rows: self - .grid - .row_labels + col_levels: col_levels(cols), + col_groups: col_groups(cols), + row_levels: rows.levels(), + rows: rows + .body_from(0) .iter() - .enumerate() - .map(|(r, label)| RenderRow { - label: label.clone(), - cells: (0..self.grid.col_labels.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() - }, + .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(), } - }) - .collect(), + } + }; + Some(row) }) .collect(), categories: entries @@ -215,6 +302,52 @@ impl RenderCatRow { } } +/// 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> { + (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 { + let mut runs: Vec = 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() @@ -257,11 +390,8 @@ mod tests { client.bootstrap(Snapshot { view, seed: vec![ - Projection::Grid(GridUpdate::RowLabels(vec!["North".into(), "South".into()])), - Projection::Grid(GridUpdate::ColLabels(vec![ - "Revenue".into(), - "Total".into(), - ])), + 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())), ( @@ -280,17 +410,20 @@ mod tests { #[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, + 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, + !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, + !model.rows[1].cells()[1].editable, "and neither is a slot fit_to_labels padded" ); } @@ -301,11 +434,11 @@ mod tests { #[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_labels, vec!["Revenue", "Total"]); - assert_eq!(model.rows[1].label, "South"); + 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 @@ -320,13 +453,13 @@ mod tests { let model = client_with_grid(view).model(); assert!( - model.rows[1].cells[0].selected, + 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) + .flat_map(|r| r.cells()) .filter(|c| c.selected) .count(); assert_eq!(elsewhere, 1, "exactly one cell is selected"); @@ -340,7 +473,11 @@ mod tests { 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), + !model + .rows + .iter() + .flat_map(|r| r.cells()) + .any(|c| c.selected), "a cursor the server has not projected a row for highlights nothing" ); } @@ -456,7 +593,12 @@ mod tests { 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_labels"][0], "Revenue"); + 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" diff --git a/crates/improvise-wasm-client/src/socket.rs b/crates/improvise-wasm-client/src/socket.rs index 27dd49e2..6eed16dd 100644 --- a/crates/improvise-wasm-client/src/socket.rs +++ b/crates/improvise-wasm-client/src/socket.rs @@ -157,6 +157,7 @@ impl Client { mod tests { use super::*; use crate::init; + use improvise_protocol::AxisFrame; use improvise_protocol::{ CellDisplay, CellId, GridUpdate, Projection, RecordCell, Rejection, Snapshot, ViewState, }; @@ -257,12 +258,12 @@ mod tests { (0, 0), CellDisplay::number("1200"), )]))); - let rows = Command::Project(Projection::Grid(GridUpdate::RowLabels(vec![ - "North".into(), - ]))); - let cols = Command::Project(Projection::Grid(GridUpdate::ColLabels(vec![ - "Revenue".into(), - ]))); + let rows = Command::Project(Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([ + "North", + ])))); + let cols = Command::Project(Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([ + "Revenue", + ])))); let mut early = init(); early.fold_frame(ServerFrame::Commands(vec![ @@ -306,8 +307,8 @@ mod tests { client.bootstrap(Snapshot { view: ViewState::default(), seed: vec![ - Projection::Grid(GridUpdate::RowLabels(vec!["North".into()])), - Projection::Grid(GridUpdate::ColLabels(vec!["Revenue".into()])), + Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))), + Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))), Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)])), // Without this the client has no basis to claim and // composes nothing at all (ADR 001) — which is correct diff --git a/crates/improvise-wasm-client/src/state.rs b/crates/improvise-wasm-client/src/state.rs index e977e358..b1647cb9 100644 --- a/crates/improvise-wasm-client/src/state.rs +++ b/crates/improvise-wasm-client/src/state.rs @@ -58,7 +58,7 @@ pub struct ClientState { /// The second half of resolving a cursor key. `GridCache::extent` says /// how big the grid is; this says how much of it is on screen, which is /// what decides whether a move has to scroll. It is **not** derivable - /// from the cache: `row_labels.len()` is `min(window, grid − offset)`, so + /// from the cache: `row_frame.len()` is `min(window, grid − offset)`, so /// it agrees with the window except near the bottom edge, which is /// precisely where the scroll arithmetic matters. /// @@ -131,6 +131,7 @@ impl ClientState { mod tests { use super::*; use improvise_core::view::Axis; + use improvise_protocol::AxisFrame; use improvise_protocol::{ CategoryRow, CategoryTreeUpdate, CellDisplay, GridUpdate, TileEntry, ViewSummary, }; @@ -145,14 +146,12 @@ mod tests { fn grid_projections() -> Vec { vec![ - Command::Project(Projection::Grid(GridUpdate::RowLabels(vec![ - "North".into(), - "South".into(), - ]))), - Command::Project(Projection::Grid(GridUpdate::ColLabels(vec![ - "Revenue".into(), - "Cost".into(), - ]))), + Command::Project(Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([ + "North", "South", + ])))), + Command::Project(Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([ + "Revenue", "Cost", + ])))), Command::Project(Projection::Grid(GridUpdate::ColWidths(vec![8, 8]))), Command::Project(Projection::Grid(GridUpdate::Cells(vec![ ((0, 0), CellDisplay::number("1200")), @@ -172,12 +171,12 @@ mod tests { (0, 0), CellDisplay::number("1200"), )]))), - Command::Project(Projection::Grid(GridUpdate::RowLabels(vec![ - "North".into(), - ]))), - Command::Project(Projection::Grid(GridUpdate::ColLabels(vec![ - "Revenue".into(), - ]))), + Command::Project(Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([ + "North", + ])))), + Command::Project(Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([ + "Revenue", + ])))), ]); assert_eq!( early.grid.cell(0, 0), @@ -186,12 +185,12 @@ mod tests { ); let ordered = folded(&[ - Command::Project(Projection::Grid(GridUpdate::RowLabels(vec![ - "North".into(), - ]))), - Command::Project(Projection::Grid(GridUpdate::ColLabels(vec![ - "Revenue".into(), - ]))), + Command::Project(Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([ + "North", + ])))), + Command::Project(Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([ + "Revenue", + ])))), Command::Project(Projection::Grid(GridUpdate::Cells(vec![( (0, 0), CellDisplay::number("1200"), diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index bab1b46a..4c3c34cf 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -80,7 +80,7 @@ fn first_editable(model: &RenderModel) -> (usize, usize) { .iter() .enumerate() .find_map(|(r, row)| { - row.cells + row.cells() .iter() .position(|cell| cell.editable) .map(|c| (r, c)) @@ -110,18 +110,19 @@ fn a_browser_client_seeds_types_a_number_and_sees_it_come_back() { model .rows .iter() - .map(|r| r.label.as_str()) + .flat_map(|r| r.labels()) .collect::>(), vec!["North", "South"], "the seed must carry the row labels framing the cells" ); - assert_eq!(model.col_labels, vec!["Revenue", "Cost"]); + assert_eq!(model.col_levels[0], vec!["Revenue", "Cost"]); assert_eq!( - model.rows[0].cells[0].text, "1,200", + model.rows[0].cells()[0].text, + "1,200", "the value arrives *formatted* — the separator is the server's view \ format, and a client that re-derived the text would not have it" ); - assert!(model.rows[0].cells[0].numeric); + assert!(model.rows[0].cells()[0].numeric); // ── typing a number ───────────────────────────────────────────────── let (row, col) = first_editable(&model); @@ -161,7 +162,7 @@ fn a_browser_client_seeds_types_a_number_and_sees_it_come_back() { "folding the fan-out must move the client" ); assert_eq!( - client.model().rows[row].cells[col].text, + client.model().rows[row].cells()[col].text, "7,500", "the page redraws from the server's formatting, never from the \ \"7500\" the user typed" @@ -201,7 +202,7 @@ fn an_aggregated_cell_reaches_the_page_as_not_editable() { model .rows .iter() - .flat_map(|r| &r.cells) + .flat_map(|r| r.cells()) .all(|c| !c.editable), "every cell here aggregates over Product, so none may be written" ); @@ -234,5 +235,5 @@ fn a_resize_reprojects_the_grid_at_the_new_extent() { 2, "the resize must bring the rest of the grid" ); - assert_eq!(client.model().col_labels, vec!["Revenue", "Cost"]); + assert_eq!(client.model().col_levels[0], vec!["Revenue", "Cost"]); } diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 1c0427be..346bf650 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -270,7 +270,7 @@ fn client_of(app: &App, window: (usize, usize)) -> ClientState { /// The fixture is six rows in a three-row window, and the keys walk past the /// bottom of the window (so `row_offset` must move) and then past the bottom /// of the *grid* (so the clamp must hold). A client reading its bound off -/// `row_labels.len()` would stop at row 2 and never scroll; one that clamped +/// `row_frame.len()` would stop at row 2 and never scroll; one that clamped /// against nothing would run off the end. Both are visible here. #[test] fn a_locally_resolved_cursor_move_lands_where_the_host_would_put_it() { diff --git a/crates/improvise-worker-server/src/worker.rs b/crates/improvise-worker-server/src/worker.rs index 056cd53b..a2af7b45 100644 --- a/crates/improvise-worker-server/src/worker.rs +++ b/crates/improvise-worker-server/src/worker.rs @@ -360,8 +360,8 @@ mod tests { fn the_bootstrap_seeds_the_cells_the_reported_extent_can_show() { let mut worker = worker(); let grid = connect(&mut worker); - assert_eq!(grid.row_labels, vec!["North", "South"]); - assert_eq!(grid.col_labels, vec!["Revenue"]); + assert_eq!(grid.row_frame.leaf_labels(), vec!["North", "South"]); + assert_eq!(grid.col_frame.leaf_labels(), vec!["Revenue"]); } /// There is no connection to close, so the rule is enforced by dropping. diff --git a/crates/improvise-worker-server/tests/post_message.rs b/crates/improvise-worker-server/tests/post_message.rs index 9146db3b..66bdfa24 100644 --- a/crates/improvise-worker-server/tests/post_message.rs +++ b/crates/improvise-worker-server/tests/post_message.rs @@ -168,7 +168,10 @@ fn the_worker_loads_projects_edits_and_persists_over_post_message() { let mut main = MainThread::boot(storage, &path); main.post(&ClientFrame::Viewport { rows: 10, cols: 4 }); - assert_eq!(main.grid.row_labels, vec!["North", "South", "East"]); + assert_eq!( + main.grid.row_frame.leaf_labels(), + vec!["North", "South", "East"] + ); assert_eq!(main.text_at(0, 0), "10"); let cell = main.cell(2, 0); @@ -295,11 +298,11 @@ fn a_resize_reprojects_the_grid_although_the_document_is_unchanged() { let (storage, path) = origin(); let mut main = MainThread::boot(storage, &path); main.post(&ClientFrame::Viewport { rows: 1, cols: 4 }); - assert_eq!(main.grid.row_labels.len(), 1, "one row fits"); + assert_eq!(main.grid.row_frame.len(), 1, "one row fits"); main.post(&ClientFrame::Viewport { rows: 10, cols: 4 }); assert_eq!( - main.grid.row_labels.len(), + main.grid.row_frame.len(), 3, "growing the window must deliver the rows it can now show" ); @@ -348,16 +351,19 @@ fn the_shipped_demo_seeds_a_worker_whose_every_cell_is_settable() { main.post(&ClientFrame::Viewport { rows: 40, cols: 20 }); assert_eq!( - main.grid.row_labels, + main.grid.row_frame.leaf_labels(), vec!["Espresso", "Latte", "Cold Brew", "Pastry"] ); - assert_eq!(main.grid.col_labels, vec!["Revenue", "Cost", "Profit"]); + assert_eq!( + main.grid.col_frame.leaf_labels(), + vec!["Revenue", "Cost", "Profit"] + ); assert_eq!(main.text_at(0, 2), "12,200", "Profit is live on arrival"); // `cell` panics on an aggregate, which is exactly the failure a visitor // meets as a cell that does not answer a click (ADR 002, gotcha 42). - for row in 0..main.grid.row_labels.len() { - for col in 0..main.grid.col_labels.len() { + for row in 0..main.grid.row_frame.len() { + for col in 0..main.grid.col_frame.len() { main.cell(row, col); } } diff --git a/crates/improvise-ws-server/tests/socket.rs b/crates/improvise-ws-server/tests/socket.rs index 78ebc8aa..9ba3ba7e 100644 --- a/crates/improvise-ws-server/tests/socket.rs +++ b/crates/improvise-ws-server/tests/socket.rs @@ -88,7 +88,7 @@ fn a_client_on_a_socket_is_seeded_and_gets_its_edit_back() { compose a write at all (ADR 001)" ); assert_eq!( - grid.row_labels, + grid.row_frame.leaf_labels(), vec!["North".to_string(), "South".to_string()] ); assert_eq!(grid.cells[0][0].text, ""); @@ -235,7 +235,7 @@ fn the_url_path_picks_the_document() { } } assert_eq!( - grid.row_labels, + grid.row_frame.leaf_labels(), vec![row.to_string()], "/{path} served the wrong document" ); diff --git a/src/draw.rs b/src/draw.rs index daf53f4a..75882eb2 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -378,7 +378,6 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) { f.render_widget( GridWidget::new( &grid_cache, - app.model_state.workbook.active_view(), app.cursor(), &app.model_state.workbook.active_view, &app.layout, diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 2b2cbeb7..9fabade0 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -6,10 +6,10 @@ use ratatui::{ }; use unicode_width::UnicodeWidthStr; -use crate::protocol::{CellId, GridCache}; +use crate::protocol::{CellId, FrameEntry, GridCache}; use crate::ui::app::{AppMode, ViewCursor}; -use crate::ui::viewport::{MAX_ROW_HEADER_W, MIN_COL_WIDTH, MIN_ROW_HEADER_W}; -use crate::view::{AxisEntry, GridLayout, View}; +use crate::ui::viewport::MIN_COL_WIDTH; +use crate::view::GridLayout; /// Subtle dark-gray background used to highlight the row containing the cursor. const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237); @@ -25,19 +25,23 @@ const GROUP_COLLAPSED: &str = "▶"; /// browser client answers it, and that divergence is the whole thing /// projecting exists to prevent. /// -/// Two things are still not on the cache and are passed separately. It is -/// worth being precise about why, because each is a different kind of -/// absence: +/// **The frame comes off the cache too** (improvise-anr9). `GridCache` +/// carries an `AxisFrame` per axis — the header levels, the label *tuples* +/// and the groups with their collapse state — so the multi-level headers, +/// the repeat suppression, the group spans and the pieces below are all +/// read rather than derived, and a browser painting the same frame paints +/// the same thing. It used to be derived here from `GridLayout` + +/// `View::is_group_collapsed` while the protocol carried +/// `GridLayout::row_label`'s slash-joined form — the rendering +/// `two_row_categories_produce_cross_product_labels` asserts must not +/// appear — so the browser drew `Food/Alice`. /// -/// - **`layout` and `view` frame the grid.** `GridCache` carries *flat* -/// labels — `GridLayout::row_label` joins a multi-level tuple with `/` — -/// while the TUI draws one header row per level with repeat suppression, -/// plus inline group headers carrying a collapse indicator. Neither the -/// tuples nor the groups exist in the protocol, so the frame is still -/// derived here from `GridLayout` + `View::is_group_collapsed`. That is a -/// **gap in the cache**, not a decision: the browser grid has no groups -/// and no multi-level headers today, and that is the divergence this -/// comment is here to keep visible. +/// Two things are still not on the cache and are passed separately, each a +/// different kind of absence: +/// +/// - **`layout` resolves a records-mode column** (`record_col`), and only +/// that. It keys `drill_state`'s pending edits, so it is session-local by +/// design and correct to keep off the wire (gotcha 1). /// - **`drill_state` is session-local residue** (gotcha 30). Pending drill /// edits belong to whoever staged them and are deliberately not projected, /// so they shadow the cache rather than being on it. @@ -47,7 +51,6 @@ const GROUP_COLLAPSED: &str = "▶"; /// the scrolling arithmetic below is unchanged. pub struct GridWidget<'a> { pub cache: &'a GridCache, - pub view: &'a View, /// This client's cursor in the active view (improvise-8uz). Passed in /// rather than read off `View`, which no longer carries per-client state. pub cursor: ViewCursor, @@ -63,7 +66,6 @@ impl<'a> GridWidget<'a> { #[allow(clippy::too_many_arguments)] pub fn new( cache: &'a GridCache, - view: &'a View, cursor: ViewCursor, view_name: &'a str, layout: &'a GridLayout, @@ -74,7 +76,6 @@ impl<'a> GridWidget<'a> { ) -> Self { Self { cache, - view, cursor, view_name, layout, @@ -86,66 +87,30 @@ impl<'a> GridWidget<'a> { } fn render_grid(&self, area: Rect, buf: &mut Buffer) { - let view = self.view; let layout = self.layout; let (sel_row, sel_col) = self.cursor.selected; let row_offset = self.cursor.row_offset; let col_offset = self.cursor.col_offset; - let n_col_levels = layout.col_cats.len().max(1); - let n_row_levels = layout.row_cats.len().max(1); + // Everything about the frame is the cache's answer (improvise-anr9): + // how many header levels, what each level's label is, which group a + // position sits in and whether that group is collapsed. Nothing here + // reads `GridLayout` or `View`, so a browser folding the same + // `AxisFrame` draws the same header. + let rows = &self.cache.row_frame; + let cols = &self.cache.col_frame; + let n_col_levels = cols.levels(); let col_widths = &self.cache.col_widths; - // How many columns there are is the cache's answer, not the - // layout's: the two agree by construction (the cache is built over - // the whole grid), and asking the cache keeps the widths, the labels - // and the count one frame rather than two that could disagree - // (gotcha 33c). - let col_count = self.cache.col_labels.len(); + let col_count = cols.len(); - // ── Adaptive row header widths ─────────────────────────────── - let data_row_items: Vec<&Vec> = layout - .row_items - .iter() - .filter_map(|e| { - if let AxisEntry::DataItem(v) = e { - Some(v) - } else { - None - } - }) - .collect(); - - let sub_widths: Vec = (0..n_row_levels) - .map(|d| { - let max_label = data_row_items - .iter() - .filter_map(|v| v.get(d)) - .map(|s| s.width() as u16) - .max() - .unwrap_or(0); - (max_label + 1).clamp(MIN_ROW_HEADER_W, MAX_ROW_HEADER_W) - }) - .collect(); + let sub_widths: Vec = rows.header_widths(); let row_header_width: u16 = sub_widths.iter().sum(); - // Flat list of data-only column tuples for repeat-suppression in headers - let data_col_items: Vec<&Vec> = layout - .col_items + let has_col_groups = cols + .entries .iter() - .filter_map(|e| { - if let AxisEntry::DataItem(v) = e { - Some(v) - } else { - None - } - }) - .collect(); - - let has_col_groups = layout - .col_items - .iter() - .any(|e| matches!(e, AxisEntry::GroupHeader { .. })); + .any(|e| matches!(e, FrameEntry::Group(_))); // Compute how many columns fit starting from col_offset. let data_area_width = area.width.saturating_sub(row_header_width); @@ -176,10 +141,6 @@ impl<'a> GridWidget<'a> { let _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 }; - let visual_row_start = layout - .data_row_to_visual(row_offset) - .unwrap_or(layout.row_items.len()); - let mut y = area.y; // Optional column group header row @@ -200,22 +161,11 @@ impl<'a> GridWidget<'a> { break; } let cw = col_w_at(ci) as usize; - let col_group = layout.col_group_for(ci); - let group_name = col_group.as_ref().map(|(_, g)| g.clone()); - let label = if group_name != prev_group { - match &col_group { - Some((cat, g)) => { - let indicator = if view.is_group_collapsed(cat, g) { - GROUP_COLLAPSED - } else { - GROUP_EXPANDED - }; - format!("{indicator} {g}") - } - None => String::new(), - } - } else { - String::new() + let col_group = cols.group_of(ci); + let group_name = col_group.map(|g| g.name.clone()); + let label = match (group_name != prev_group, col_group) { + (true, Some(g)) => format!("{} {}", indicator(g.collapsed), g.name), + _ => String::new(), }; prev_group = group_name; buf.set_string( @@ -249,25 +199,14 @@ impl<'a> GridWidget<'a> { break; } let cw = col_w_at(ci) as usize; - let label = if layout.col_cats.is_empty() { - self.cache.col_labels[ci].clone() - } else { - if show_sublabel(ci, col_offset, d, &data_col_items) { - data_col_items[ci][d].clone() - } else { - String::new() - } + let label = match cols.shows_level(ci, col_offset, d) { + true => cols.label(ci, d).to_string(), + false => String::new(), }; // Underline columns that share the same ancestor group as // sel_col through level d. At the bottom level this matches // only sel_col; at higher levels it spans all sub-columns. - let in_sel_group = if layout.col_cats.is_empty() { - ci == sel_col - } else if sel_col < data_col_items.len() && ci < data_col_items.len() { - data_col_items[ci][..=d] == data_col_items[sel_col][..=d] - } else { - false - }; + let in_sel_group = cols.same_prefix(ci, sel_col, d); let styled = if in_sel_group { header_style.add_modifier(Modifier::UNDERLINED) } else { @@ -301,21 +240,13 @@ impl<'a> GridWidget<'a> { .fg(Color::Green) .add_modifier(Modifier::BOLD); let mut data_row_idx = row_offset; - for entry in &layout.row_items[visual_row_start..] { + for entry in rows.body_from(row_offset) { if y >= area.y + area.height { break; } match entry { - AxisEntry::GroupHeader { - cat_name, - group_name, - } => { - let indicator = if view.is_group_collapsed(cat_name, group_name) { - GROUP_COLLAPSED - } else { - GROUP_EXPANDED - }; - let label = format!("{indicator} {group_name}"); + FrameEntry::Group(group) => { + let label = format!("{} {}", indicator(group.collapsed), group.name); buf.set_string( area.x, y, @@ -340,7 +271,7 @@ impl<'a> GridWidget<'a> { ); } } - AxisEntry::DataItem(_) => { + FrameEntry::Item(_) => { let ri = data_row_idx; data_row_idx += 1; @@ -369,16 +300,11 @@ impl<'a> GridWidget<'a> { // Multi-level row header — one sub-column per row category let mut hx = area.x; - for d in 0..n_row_levels { - let sw = sub_widths[d] as usize; - let label = if layout.row_cats.is_empty() { - self.cache.row_labels[ri].clone() - } else { - if show_sublabel(ri, row_offset, d, &data_row_items) { - data_row_items[ri][d].clone() - } else { - String::new() - } + for (d, width) in sub_widths.iter().enumerate() { + let sw = *width as usize; + let label = match rows.shows_level(ri, row_offset, d) { + true => rows.label(ri, d).to_string(), + false => String::new(), }; buf.set_string( hx, @@ -386,7 +312,7 @@ impl<'a> GridWidget<'a> { format!("{: Widget for GridWidget<'a> { } } -/// Decide whether the multi-level header sub-label at depth `d` for the data -/// entry `idx` should be rendered, or blanked because it repeats the previous -/// visible entry's prefix. `first_rendered` is the index of the first entry -/// actually on screen (the row/col scroll offset): that entry always shows its -/// full labels so a scrolled viewport keeps its group context. -fn show_sublabel(idx: usize, first_rendered: usize, d: usize, items: &[&Vec]) -> bool { - idx == first_rendered || items[idx][..=d] != items[idx - 1][..=d] +/// The collapse glyph. The *fact* — whether the group is collapsed — is on +/// the frame; only its spelling is the terminal's (design-principles §5). +fn indicator(collapsed: bool) -> &'static str { + match collapsed { + true => GROUP_COLLAPSED, + false => GROUP_EXPANDED, + } } fn truncate(s: &str, max_width: usize) -> String { @@ -603,7 +529,7 @@ fn truncate(s: &str, max_width: usize) -> String { mod tests { use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; - use super::{GridWidget, show_sublabel}; + use super::GridWidget; use crate::formula::parse_formula; use crate::model::cell::{CellKey, CellValue}; use crate::ui::app::{App, AppMode}; @@ -641,7 +567,6 @@ mod tests { let view_name = wb.active_view.clone(); GridWidget::new( &cache, - wb.active_view(), crate::ui::app::ViewCursor::default(), &view_name, &layout, @@ -968,42 +893,6 @@ mod tests { assert!(text.contains("2025"), "expected '2025' in:\n{text}"); } - // ── Multi-level header repeat suppression ───────────────────────────────── - - /// Items: [A,x], [A,y], [B,z] — multi-level header tuples. - fn sublabel_items() -> Vec> { - [["A", "x"], ["A", "y"], ["B", "z"]] - .iter() - .map(|t| t.iter().map(|s| s.to_string()).collect()) - .collect() - } - - /// Bug (improvise-jjx): suppression compared against the entry at - /// `idx - 1` even when that entry is scrolled off-screen. The first - /// RENDERED entry must always show its full labels, otherwise scrolling - /// blanks the group label at the top of the viewport. - #[test] - fn first_rendered_entry_always_shows_sublabel_when_scrolled() { - let items = sublabel_items(); - let refs: Vec<&Vec> = items.iter().collect(); - // Scrolled so index 1 ([A,y]) is the first visible entry. Its level-0 - // prefix matches off-screen index 0 ([A,x]) — it must still show "A". - assert!( - show_sublabel(1, 1, 0, &refs), - "first rendered entry must show its label even when its prefix matches the off-screen entry above" - ); - } - - #[test] - fn repeated_prefix_below_first_rendered_entry_is_suppressed() { - let items = sublabel_items(); - let refs: Vec<&Vec> = items.iter().collect(); - // Unscrolled: index 1 repeats index 0's level-0 prefix → suppressed. - assert!(!show_sublabel(1, 0, 0, &refs)); - // Leaf level differs → shown. - assert!(show_sublabel(1, 0, 1, &refs)); - } - // ── Characterization: the whole frame, byte for byte ───────────────────── /// A grid with row groups, column groups, a page axis, multi-level row @@ -1063,13 +952,4 @@ mod tests { "│ │\n", "└──────────────────────────────────────────┘", ); - - #[test] - fn changed_prefix_is_always_shown() { - let items = sublabel_items(); - let refs: Vec<&Vec> = items.iter().collect(); - assert!(show_sublabel(0, 0, 0, &refs)); - assert!(show_sublabel(2, 0, 0, &refs)); // B != A - assert!(show_sublabel(2, 1, 0, &refs)); // scrolled, still differs - } } diff --git a/tests/web_demo_model.rs b/tests/web_demo_model.rs index 2f2f0cc9..7d7543bb 100644 --- a/tests/web_demo_model.rs +++ b/tests/web_demo_model.rs @@ -52,39 +52,14 @@ fn every_visible_cell_in_the_web_demo_is_settable() { other => panic!( "cell ({r},{c}) — row {:?}, column {:?} — is {other:?}, not a Record, so the \ page will show it as un-editable", - grid.row_labels.get(r), - grid.col_labels.get(c), + grid.row_frame.item(r), + grid.col_frame.item(c), ), } } } } -/// The demo is legible only if its headers are single-level. `GridCache` -/// slash-joins a multi-level tuple into one label (gotcha 44), so a second -/// category on an axis reads as `Espresso/North` in the browser. That is a -/// known gap in the shared layer; the demo must not stand on it. -#[test] -fn the_web_demo_has_one_category_per_visible_axis() { - let app = demo_app(); - let (rows, cols) = (app.layout.row_count(), app.layout.col_count()); - let grid = grid_of( - &app, - Viewport { - row_offset: 0, - col_offset: 0, - rows, - cols, - }, - ); - for label in grid.row_labels.iter().chain(grid.col_labels.iter()) { - assert!( - !label.contains('/'), - "{label:?} is a joined multi-level label; the browser cannot draw header levels yet" - ); - } -} - /// The payoff a visitor should see without pressing a key: a formula column /// beside the two columns that feed it, so an edit visibly moves it. #[test] @@ -99,9 +74,12 @@ fn the_web_demo_shows_a_formula_beside_its_inputs() { cols: app.layout.col_count(), }, ); - assert_eq!(grid.col_labels, vec!["Revenue", "Cost", "Profit"]); assert_eq!( - grid.row_labels, + grid.col_frame.leaf_labels(), + vec!["Revenue", "Cost", "Profit"] + ); + assert_eq!( + grid.row_frame.leaf_labels(), vec!["Espresso", "Latte", "Cold Brew", "Pastry"] ); let profit = grid.cell(0, 2).expect("the Profit cell"); diff --git a/web/app.js b/web/app.js index 03fee4d1..1d564db6 100644 --- a/web/app.js +++ b/web/app.js @@ -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) { diff --git a/web/style.css b/web/style.css index 901908f1..db18f505 100644 --- a/web/style.css +++ b/web/style.css @@ -126,6 +126,13 @@ th { font-weight: 600; } +/* A group header, on either axis. The TUI draws these green and bold; the + glyph is already in the text (`indicator` in app.js). */ +th.group { + color: var(--accent, inherit); + font-weight: 700; +} + td.numeric { text-align: right; }