feat(protocol): the grid's frame on the wire, as AxisFrame

`GridCache::row_labels`/`col_labels` were `Vec<String>` built by
`GridLayout::row_label`, which slash-joins a multi-level tuple — the exact
rendering `two_row_categories_produce_cross_product_labels` asserts must not
appear. So the protocol carried a form of the labels the TUI itself
considered wrong, and `improvise-wasm-client/src/render.rs` drew precisely
that form: the browser showed `Food/Alice` where the TUI showed two header
columns, with no groups and no collapse indicators (gotcha 44).

Both fields become one `AxisFrame` per axis, carrying the five facts gotcha
44 enumerates: the categories (hence the header levels), the label *tuples*
and the inline group headers, the group a position sits in, and each group's
collapse state. `record_col` stays off — session-local, keys pending drill
edits, correct to keep off.

It is one type and not a `row_levels` beside `row_labels`, because two
answers to "what are the labels" is the disease (improvise-edp). The rules
that used to be written twice are methods on it and are now called from both
front ends: `shows_level` (repeat suppression, improvise-jjx's fix),
`group_of`, `body_from`, and `header_widths` — the row-header width rule,
which existed twice already, inline in `render_grid` and again in
`viewport::compute_row_header_width` (improvise-1125).

`GridWidget` loses its `&View`: nothing in it reads the view any more.

Evidence. `the_whole_frame_is_unchanged` — the exact-buffer characterization
over a grid with row groups, column groups, a page axis, multi-level headers
and a total row — passes byte for byte, so the TUI is unchanged. The new
`tests/grid_frame_agreement.rs` drives the widget into a ratatui buffer and
the browser client through a `Snapshot` of the *same* cache, and compares
what each draws; re-introducing the slash-join on either side was injected
and watched to fail before the test was kept.

`tests/web_demo_model.rs::the_web_demo_has_one_category_per_visible_axis` is
deleted: it guarded the gap this closes, and a consolidation that keeps its
compatibility test is unfinished.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-19 22:43:47 -07:00
parent 97ea48957e
commit 301a74821a
25 changed files with 979 additions and 464 deletions
Generated
+3
View File
@@ -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]]
+4 -4
View File
@@ -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"
);
+94 -23
View File
@@ -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<usize>,
) -> AxisFrame {
let total = items
.iter()
.filter(|e| matches!(e, AxisEntry::DataItem(_)))
.count();
let mut data_idx = 0usize;
let mut entries: Vec<FrameEntry> = Vec::new();
let mut pending: Vec<FrameEntry> = 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<GridUpdate>
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();
+4 -2
View File
@@ -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));
+29 -27
View File
@@ -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<String>> = layout
.row_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
}
})
.collect();
let sub_widths: Vec<u16> = (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.
+4
View File
@@ -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"
+2 -2
View File
@@ -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),
] {
+5 -4
View File
@@ -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
+7 -3
View File
@@ -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 {
+416 -66
View File
@@ -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<String>),
}
/// One axis of the grid's *frame*: the header levels, the label tuples and
/// the groups (improvise-anr9/s1xw).
///
/// This replaced a `Vec<String>` 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<String>,
pub entries: Vec<FrameEntry>,
}
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<S: Into<String>>(labels: impl IntoIterator<Item = S>) -> 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<Item = &Vec<String>> {
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<String> {
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<String>> {
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<usize> {
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<u16> {
let items: Vec<&Vec<String>> = 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<String>,
pub col_labels: Vec<String>,
/// 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<u16>,
/// 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<Vec<CellDisplay>>,
/// 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<usize> = (0..new.len())
.filter(|&i| old.get(i) != Some(&new[i]))
let old_items: Vec<&Vec<String>> = old.items().collect();
let new_items: Vec<&Vec<String>> = new.items().collect();
let stale: Vec<usize> = (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<String>),
ColLabels(Vec<String>),
/// The row axis's whole frame — levels, label tuples and groups
/// (improvise-anr9). It replaced a `Vec<String>` of slash-joined labels;
/// see [`AxisFrame`].
RowFrame(AxisFrame),
ColFrame(AxisFrame),
ColWidths(Vec<u16>),
/// 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),
+17 -8
View File
@@ -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()),
+15 -10
View File
@@ -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());
}
+186 -44
View File
@@ -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<String>,
/// The column header, one entry per level, each with one label per
/// column and `""` where the label repeats the column to its left.
/// Level-major because that is how it is drawn: one line per level.
pub col_levels: Vec<Vec<String>>,
/// The runs of columns that share a group, in column order. Empty when
/// the column axis has no groups at all — which is a fact, not a gap,
/// and the page draws no group line for it.
pub col_groups: Vec<RenderColGroup>,
/// How many row-header levels every [`RenderRow::Data`] carries.
pub row_levels: usize,
pub rows: Vec<RenderRow>,
/// The category panel, flattened by the one flattening.
pub categories: Vec<RenderCatRow>,
@@ -78,11 +96,59 @@ pub struct RenderModel {
pub editing: Option<String>,
}
/// 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<String>,
cells: Vec<RenderCell>,
},
}
impl RenderRow {
/// The cells of a data row. A group header spans the grid and has none,
/// which is the empty slice rather than a panic — the caller asking for
/// cells of a header row is asking a reasonable question about a row it
/// did not choose.
pub fn cells(&self) -> &[RenderCell] {
match self {
RenderRow::Data { cells, .. } => cells,
RenderRow::Group { .. } => &[],
}
}
/// The row header's labels, one per level. A group header has one label
/// of its own.
pub fn labels(&self) -> &[String] {
match self {
RenderRow::Data { labels, .. } => labels,
RenderRow::Group { .. } => &[],
}
}
}
/// A run of columns under one group header.
///
/// `label` is empty for a run of columns in no group, which keeps the runs a
/// partition of the columns: the spans sum to the column count, so a page
/// laying them out as `colspan` cannot leave a hole.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderRow {
pub struct RenderColGroup {
pub label: String,
pub cells: Vec<RenderCell>,
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<Vec<String>> {
(0..cols.levels())
.map(|level| {
(0..cols.len())
.map(|c| match cols.shows_level(c, 0, level) {
true => cols.label(c, level).to_string(),
false => String::new(),
})
.collect()
})
.collect()
}
/// The column groups as runs, by walking `AxisFrame::group_of` across the
/// columns and coalescing equal answers.
///
/// It returns nothing at all when no column is in a group, because "this
/// grid has no column groups" and "every column's group is blank" are
/// different statements and the page draws a line for only one of them —
/// the same asymmetry `GridCache::totals` has (gotcha 33).
fn col_groups(cols: &AxisFrame) -> Vec<RenderColGroup> {
let mut runs: Vec<RenderColGroup> = Vec::new();
let mut any = false;
for c in 0..cols.len() {
let group = cols.group_of(c);
any |= group.is_some();
let label = group.map(|g| g.name.clone()).unwrap_or_default();
let collapsed = group.is_some_and(|g| g.collapsed);
match runs.last_mut() {
Some(run) if run.label == label && run.collapsed == collapsed => run.span += 1,
_ => runs.push(RenderColGroup {
label,
collapsed,
span: 1,
}),
}
}
match any {
true => runs,
false => Vec::new(),
}
}
impl Client {
pub fn model(&self) -> RenderModel {
self.state().render_model()
@@ -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"
+9 -8
View File
@@ -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
+20 -21
View File
@@ -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<Command> {
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"),
@@ -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<_>>(),
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"]);
}
@@ -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() {
+2 -2
View File
@@ -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.
@@ -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);
}
}
+2 -2
View File
@@ -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"
);
-1
View File
@@ -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,
+59 -179
View File
@@ -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<String>> = layout
.row_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
}
})
.collect();
let sub_widths: Vec<u16> = (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<u16> = 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<String>> = 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!("{:<width$}", truncate(&label, sw), width = sw),
row_style,
);
hx += sub_widths[d];
hx += width;
}
for ci in visible_col_range.clone() {
@@ -568,13 +494,13 @@ impl<'a> 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<String>]) -> 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<Vec<String>> {
[["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<String>> = 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<String>> = 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<String>> = 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
}
}
+7 -29
View File
@@ -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");
+64 -13
View File
@@ -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) {
+7
View File
@@ -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;
}