Merge gh/main into chore/orch-beads

This commit is contained in:
Edward Langley
2026-09-20 00:40:50 -07:00
28 changed files with 2345 additions and 517 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",
@@ -938,6 +940,7 @@ dependencies = [
"improvise-core",
"serde",
"serde_json",
"unicode-width",
]
[[package]]
+7
View File
@@ -38,6 +38,13 @@ clap = { version = "4.6.7", features = ["derive"] }
enum_dispatch = "0.3.13"
[dev-dependencies]
# Only `tests/grid_frame_agreement.rs` needs it: that test drives the TUI
# widget and the browser client over ONE `GridCache` and asserts they draw
# the same grid frame (improvise-anr9). A dev-dependency, so the terminal
# binary does not grow a wasm client; `improvise-wasm-client` builds
# natively, which is how its own tests already run.
improvise-wasm-client = { path = "crates/improvise-wasm-client" }
improvise-protocol = { path = "crates/improvise-protocol" }
pest = "2.9.1"
pest_derive = "2.9.1"
pest_meta = "2.9.1"
+9 -4
View File
@@ -243,10 +243,15 @@ handed a job the store should have done.
today.** Two front ends deriving the same fact will diverge, and the divergence
is invisible until someone compares them side by side. `improvise-edp` is the
worked example — the category tree was flattened twice, two different ways, and
the fix put `viewmodel::flatten_cat_tree` where both ends call it. Gotcha 44 is
the same defect still open: the TUI derives the grid's *frame* from
`GridLayout`, the browser cannot, and so the browser renders `Food/Alice` where
the TUI renders two header columns.
the fix put `viewmodel::flatten_cat_tree` where both ends call it. Gotcha 44
was the same defect and is now closed the same way (improvise-anr9): the grid's
*frame* — header levels, label tuples, group runs and their collapse state —
is `improvise_protocol::AxisFrame`, and both `src/ui/grid.rs` and
`improvise-wasm-client/src/render.rs` ask it rather than deriving one. Note
what the second cure cost that the first did not: a `Vec<String>` of labels
had to change **type**, and a shared derivation could not be added beside the
old one — `row_levels` next to `row_labels` would have been two answers to
"what are the labels", which is the disease and not the cure.
**Sharing the table beats testing two copies of it.** Where a front end must
turn input into actions, share the mapping rather than mirroring it: two
+4 -4
View File
@@ -491,7 +491,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"
);
@@ -504,7 +504,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
@@ -518,7 +518,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"
);
@@ -1254,7 +1254,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"
);
+395 -26
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,86 @@ 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).
///
/// - **One 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 nearest header above the first in-window
/// item is carried — and only that one, or the payload grows with the
/// groups a deep scroll passed. `AxisFrame::body_from` then declines to
/// *paint* it, which is the cut `GridLayout::data_row_to_visual` always
/// made.
/// - **Consecutive headers *inside* the window all come.**
/// `[North, Q1 (collapsed), Q2, Feb]` is a real shape — a collapsed group
/// emits its header and no items — and only the nearest wins `group_of`,
/// but dropping the far one would lose the only row saying those items
/// exist. (Seen, not yet acted on, in a browser: `z` is `Upstream` and
/// `web/app.js` puts no listener on a group header.) **Above** the window
/// it is the other way
/// round: nothing there is painted, so only the nearest is kept, which is
/// the compaction the header arm makes.
/// - **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,
} => {
// Before the window opens, only the NEAREST header matters:
// it is the one `group_of` answers with, and `body_from`
// discards every header above the first in-window item
// anyway. Keeping the run would make the payload O(offset)
// in the groups above a deeply scrolled viewport, all of it
// discarded on arrival. `entries.is_empty()` is true exactly
// until the first in-window item lands.
if entries.is_empty() {
pending.clear();
}
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.
///
@@ -616,11 +707,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()));
@@ -1281,6 +1372,213 @@ mod tests {
}
}
/// Region on Row with `East` in a group that comes **last**, so the group
/// header is the final entry on the axis and nothing follows it. That is
/// the shape `axis_frame`'s trailing rule is about.
fn app_with_a_trailing_group(collapsed: bool) -> App {
let mut wb = Workbook::new("Grouped");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item("Region", "South").unwrap();
wb.add_item_in_group("Region", "East", "Late").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
for item in ["North", "South", "East"] {
let key = CellKey::new(vec![
("Region".to_string(), item.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]);
wb.model.set_cell(key, CellValue::Number(1.0));
}
if collapsed {
wb.active_view_mut().toggle_group_collapse("Region", "Late");
}
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
fn frame_shape(frame: &improvise_protocol::AxisFrame) -> Vec<String> {
frame
.entries
.iter()
.map(|e| match e {
FrameEntry::Group(g) => format!("[{}]", g.name),
FrameEntry::Item(v) => v.join("+"),
})
.collect()
}
/// A collapsed group at the bottom of the grid has no items to hang off,
/// and is still the only row that says those rows exist. A window that
/// reaches the last row therefore carries it.
///
/// It must be *seen*; it is not yet actionable in a browser. `web/app.js`
/// registers no listener on a group `<th>`, and `toggle-group-under-cursor`
/// is still in `the_command_names_that_still_need_the_host_are_these`, so
/// `z` resolves `Upstream` into `Host::handle`'s empty vec — a black hole,
/// not a slow path (`doctrine(an_upstream_key_is_a_black_hole)`). The TUI
/// can expand it today; the browser can only show it.
#[test]
fn a_window_reaching_the_last_row_carries_the_trailing_group_header() {
let app = app_with_a_trailing_group(true);
let grid = grid_of(
&app,
Viewport {
row_offset: 0,
col_offset: 0,
rows: 20,
cols: 20,
},
);
assert_eq!(
grid.row_frame.len(),
2,
"East is inside the collapsed group"
);
assert_eq!(
frame_shape(&grid.row_frame),
vec!["North", "South", "[Late]"],
"the header is the last entry, and it is what the user expands"
);
}
/// The other arm, and the one that would make a browser scrolled to the
/// middle of a document draw the bottom of it: a window that stops short
/// of the last row carries nothing below its last item.
#[test]
fn a_window_that_stops_short_does_not_carry_what_is_below_it() {
let app = app_with_a_trailing_group(true);
let grid = grid_of(
&app,
Viewport {
row_offset: 0,
col_offset: 0,
rows: 1,
cols: 20,
},
);
assert_eq!(
frame_shape(&grid.row_frame),
vec!["North"],
"South and the group below it are outside this window"
);
}
/// A window opening *inside* a group still names it — otherwise a browser
/// could not say which group a column or row belongs to — while
/// `body_from` declines to paint a header that sits above the window.
///
/// **And only the nearest one comes.** The fixture puts **two** headers
/// above the window (`Hidden` is collapsed, so it emits a header and no
/// items, and `Late`'s follows it immediately) because that is the only
/// way to tell "the nearest header comes" from "every header above
/// comes". With one group above, deleting the compaction leaves this
/// green — it is a *cost* property, O(the groups a deep scroll crossed),
/// and correctness alone cannot see it: `group_of` still finds the
/// nearest and `body_from` still starts at the first item either way.
#[test]
fn a_window_opening_inside_a_group_names_only_the_nearest() {
let app = app_with_consecutive_headers();
let grid = grid_of(
&app,
Viewport {
row_offset: 1,
col_offset: 0,
rows: 20,
cols: 20,
},
);
assert_eq!(
frame_shape(&grid.row_frame),
vec!["[Late]", "East"],
"the nearest header above the window comes with it; `Hidden` is \
above that and is not carried — nothing above the window is \
painted, so only the one `group_of` answers with is worth sending"
);
let group = grid.row_frame.group_of(0).expect("East is in Late");
assert_eq!(group.name, "Late");
assert!(!group.collapsed);
assert!(
matches!(grid.row_frame.body_from(0), [FrameEntry::Item(_)]),
"but a renderer starts at the item, not at the header above it"
);
}
/// Two consecutive group headers between two rows: `Hidden` is collapsed,
/// so it emits a header and no items, and `Late`'s header follows it
/// immediately.
fn app_with_consecutive_headers() -> App {
let mut wb = Workbook::new("Consecutive");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item_in_group("Region", "Inner", "Hidden").unwrap();
wb.add_item_in_group("Region", "East", "Late").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
for item in ["North", "Inner", "East"] {
let key = CellKey::new(vec![
("Region".to_string(), item.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]);
wb.model.set_cell(key, CellValue::Number(1.0));
}
wb.active_view_mut()
.toggle_group_collapse("Region", "Hidden");
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// Headers between two in-window rows all come, not just the nearest.
///
/// Only the nearest wins `group_of`, so a compaction that kept one would
/// still answer every question about *membership* correctly — and would
/// silently lose the `Hidden` header, which is the only thing on screen
/// that says those rows exist at all — and the only thing that *will* be
/// clickable once the browser can toggle a group. It cannot today: `z` is
/// `Upstream` and answered with nothing, so the TUI can expand it and the
/// browser can only draw it.
///
/// Above the window the rule is the opposite and that arm is
/// `a_window_opening_inside_a_group_names_it_without_painting_it`.
#[test]
fn consecutive_headers_between_two_rows_all_come() {
let app = app_with_consecutive_headers();
let grid = grid_of(
&app,
Viewport {
row_offset: 0,
col_offset: 0,
rows: 20,
cols: 20,
},
);
assert_eq!(
frame_shape(&grid.row_frame),
vec!["North", "[Hidden]", "[Late]", "East"],
"both headers sit between the two rows and both are drawn"
);
let group = grid.row_frame.group_of(1).expect("East is under Late");
assert_eq!(group.name, "Late", "the NEAREST header wins membership");
let hidden = grid
.row_frame
.entries
.iter()
.find_map(|e| match e {
FrameEntry::Group(g) if g.name == "Hidden" => Some(g),
_ => None,
})
.expect("kept");
assert!(
hidden.collapsed,
"and it arrives collapsed, which is what makes it the row that \
says those rows still exist"
);
}
fn app_with_data() -> App {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
@@ -1499,8 +1797,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 });
}
@@ -1558,7 +1856,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();
@@ -1575,8 +1873,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()
@@ -1587,7 +1885,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:?}"
@@ -1605,15 +1903,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")],
@@ -1661,8 +1959,8 @@ mod tests {
]
};
let mirror = GridCache {
row_labels: vec!["r0".into()],
col_labels: vec!["A".into(), "B".into(), "C".into()],
row_frame: AxisFrame::flat(["r0"]),
col_frame: AxisFrame::flat(["A", "B", "C"]),
col_widths: vec![8, 8, 8],
cells: vec![vec![
CellDisplay::number("1"),
@@ -1673,7 +1971,7 @@ mod tests {
..GridCache::default()
};
let desired = GridCache {
col_labels: vec!["A".into(), "Bee".into(), "C".into()],
col_frame: AxisFrame::flat(["A", "Bee", "C"]),
..mirror.clone()
};
@@ -1687,6 +1985,77 @@ mod tests {
);
}
/// The producer half of the category-rename rule, and the reason the
/// `apply` half is not cosmetic: the two compose into "wrong forever"
/// without it.
///
/// `grid_updates` diffs the cells against the **framed** mirror — the
/// mirror with the frame updates already folded in. So an `apply` that
/// kept the cells through a `cats` change made the producer agree they
/// matched and send nothing, `ClientSession::project_diff` then assigned
/// `self.grid = desired`, and the client held cells answering the old
/// coordinates with no update in the stream that would ever correct them
/// — the direction `Dirty::of` is deliberately sloppy to avoid
/// (gotcha 35).
///
/// An axis swap is the case that bites: two categories can legally share
/// an item name, so `Region = [North]` becoming `Department = [North]`
/// leaves the labels identical and the `CellId`s **valid and pointing at
/// the other record**.
#[test]
fn renaming_an_axis_category_resends_every_cell() {
let cell = |n: &str| CellDisplay::number(n);
let mirror = GridCache {
row_frame: AxisFrame {
cats: vec!["Region".into()],
entries: vec![FrameEntry::Item(vec!["North".into()])],
},
col_frame: AxisFrame::flat(["A", "B"]),
col_widths: vec![8, 8],
cells: vec![vec![cell("1"), cell("2")]],
totals: vec![cell("10"), cell("20")],
..GridCache::default()
};
let desired = GridCache {
row_frame: AxisFrame {
cats: vec!["Department".into()],
entries: mirror.row_frame.entries.clone(),
},
..mirror.clone()
};
assert_eq!(
desired.row_frame.items().collect::<Vec<_>>(),
mirror.row_frame.items().collect::<Vec<_>>(),
"precondition: only `cats` differs, so a producer comparing \
labels sees nothing"
);
let updates = grid_updates(&mirror, &desired);
let resent: Vec<(usize, usize)> = updates
.iter()
.filter_map(|u| match u {
GridUpdate::Cells(changes) => Some(changes),
_ => None,
})
.flatten()
.map(|((r, c), _)| (*r, *c))
.collect();
assert_eq!(
resent,
vec![(0, 0), (0, 1)],
"every cell answers a coordinate that has been re-identified"
);
let mut client = mirror.clone();
for update in &updates {
client.apply(update);
}
assert_eq!(
client, desired,
"and the client ends holding exactly what the server has"
);
}
/// A second view whose axes are the first one's transposed, so the two
/// grids cannot be confused for each other: "Default" rows are regions,
/// "Second" rows are measures.
@@ -1723,7 +2092,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();
@@ -2103,7 +2472,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
@@ -696,7 +696,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");
@@ -825,7 +825,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));
+22 -26
View File
@@ -3,6 +3,8 @@
use unicode_width::UnicodeWidthStr;
use improvise_protocol::header_level_widths;
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,23 @@ 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 `improvise_protocol::header_level_widths`, 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 — and it is on the
/// keypress path, so it hands the rule **borrowed** tuples rather than
/// building a frame and cloning every row. It does not reach for a `View`
/// either, 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
let items = layout.row_items.iter().filter_map(|e| match e {
AxisEntry::DataItem(v) => Some(v),
AxisEntry::GroupHeader { .. } => None,
});
header_level_widths(items, layout.row_cats.len().max(1))
.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()
.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
@@ -225,7 +225,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() {
@@ -260,7 +260,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, header_level_widths,
};
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 {
File diff suppressed because it is too large Load Diff
+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
@@ -787,7 +794,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)
@@ -1391,8 +1400,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")),
@@ -1542,8 +1551,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()),
+13 -8
View File
@@ -162,7 +162,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.
@@ -257,7 +257,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) ─────────────────────────────────────
@@ -292,8 +294,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)])),
],
});
@@ -309,9 +311,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
@@ -332,7 +334,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());
}
+249 -45
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,72 @@ 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 { .. } => &[],
}
}
/// A data row's header labels, one per level.
///
/// **Empty for a group header**, which carries its label in its own
/// `RenderRow::Group { label }` variant rather than in a one-element
/// tuple — so a caller walking `labels()` over the whole model sees the
/// data rows and nothing else. That is exactly what
/// `neither_front_end_produces_a_slash_joined_label` wants (only a data
/// row can carry a joined tuple), and a caller that means "every label on
/// screen" has to match on the variant.
pub fn labels(&self) -> &[String] {
match self {
RenderRow::Data { labels, .. } => labels,
RenderRow::Group { .. } => &[],
}
}
}
/// A run of columns under one group header.
///
/// `label` is `None` 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.
///
/// It is an `Option` rather than an empty string because an empty string is
/// a legal group *name* — `Category::add_item_in_group` accepts one, and the
/// TUI draws its indicator like any other. A sentinel would serialize that
/// group identically to an ungrouped run and the page would drop its
/// indicator (raised by Copilot on PR #23).
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderRow {
pub label: String,
pub cells: Vec<RenderCell>,
pub struct RenderColGroup {
pub label: Option<String>,
pub collapsed: bool,
pub span: usize,
}
/// One cell, with every question already answered.
@@ -140,35 +219,54 @@ 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;
// Built once for the whole frame: the per-position accessors scan,
// so asking them inside the row loop is quadratic.
let row_labels = rows.header_labels(0);
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: row_labels[r].clone(),
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 +313,58 @@ 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>> {
// `header_labels` answers per column; the page draws per *line*, so this
// is a transpose and nothing more.
let per_column = cols.header_labels(0);
(0..cols.levels())
.map(|level| {
per_column
.iter()
.map(|levels| levels[level].clone())
.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 group in cols.groups() {
any |= group.is_some();
let label = group.map(|g| g.name.clone());
let collapsed = group.is_some_and(|g| g.collapsed);
// Coalesced by NAME alone, which is what `src/ui/grid.rs` does
// (`group_name != prev_group`), and the two must agree. Collapse is
// category-scoped, so two adjacent groups from different categories
// may legally share a name and differ in it; keying on the pair
// would start a second run the TUI suppresses. The run's indicator
// is its FIRST group's, again as the TUI paints it.
match runs.last_mut() {
Some(run) if run.label == label => 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()
@@ -252,16 +402,58 @@ mod tests {
/// A 2x2 grid whose four cells cover the three id shapes plus a padded
/// slot, so one fixture answers every editability question.
fn group(cat: &str, name: &str, collapsed: bool) -> FrameEntry {
FrameEntry::Group(improvise_protocol::FrameGroup {
cat: cat.into(),
name: name.into(),
collapsed,
})
}
fn item(label: &str) -> FrameEntry {
FrameEntry::Item(vec![label.into()])
}
/// The run boundary must be the **name alone**, because that is what
/// `src/ui/grid.rs` uses (`group_name != prev_group`) and the two ends
/// must not disagree about where a span begins.
///
/// Collapse is category-scoped, so two adjacent groups from different
/// categories may legally share a name and differ in it. Keying the run
/// on the pair would start a second run — a second indicator the TUI
/// suppresses — which is a front-end divergence of exactly the kind this
/// whole change exists to remove (raised by Copilot on PR #23).
#[test]
fn column_groups_coalesce_by_name_as_the_tui_does() {
let frame = AxisFrame {
cats: vec!["Month".into()],
entries: vec![
group("Month", "Q1", false),
item("Jan"),
group("Fiscal", "Q1", true),
item("Feb"),
group("Month", "Q2", false),
item("Apr"),
],
};
assert_eq!(
col_groups(&frame)
.iter()
.map(|g| (g.label.as_deref(), g.span, g.collapsed))
.collect::<Vec<_>>(),
vec![(Some("Q1"), 2, false), (Some("Q2"), 1, false)],
"the two Q1 headers are one span, drawn with the FIRST one's \
indicator; keying on (name, collapsed) would split them"
);
}
fn client_with_grid(view: ViewState) -> Client {
let mut client = init();
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 +472,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 +496,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 +515,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 +535,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 +655,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
@@ -213,6 +213,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,
};
@@ -313,12 +314,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![
@@ -362,8 +363,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"),
@@ -24,6 +24,7 @@ use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::view::Axis;
use improvise_core::workbook::Workbook;
use improvise_protocol::ClientFrame;
use improvise_wasm_client::render::RenderRow;
use improvise_wasm_client::{Client, RenderModel};
use improvise_ws_server::host::{Addressed, Host};
@@ -71,20 +72,47 @@ fn send(
host.handle(id, msg)
}
/// The position of the first cell the render model says may be typed into.
/// Where the first settable cell is, in **both** coordinate systems.
///
/// Read off the model rather than assumed, because "which cells are
/// editable" is precisely what the client is being trusted to say.
fn first_editable(model: &RenderModel) -> (usize, usize) {
model
.rows
.iter()
.enumerate()
.find_map(|(r, row)| {
row.cells
.iter()
.position(|cell| cell.editable)
.map(|c| (r, c))
})
/// editable" is precisely what the client is being trusted to say — and
/// returned as two numbers because they are two numbers. `model.rows`
/// counts group headers; `Client::typed_cell`'s row argument does not
/// (improvise-anr9). A fixture with no groups makes them coincide, which is
/// exactly how an earlier version of this helper handed a *visual* index to
/// `typed_cell` and passed (Copilot, PR #23).
struct FirstEditable {
/// Indexes `RenderModel::rows`.
visual_row: usize,
/// Indexes the grid the server projects — what a command names.
data_row: usize,
col: usize,
}
/// Every settable cell the model offers, first per row, in both systems.
fn editable_cells(model: &RenderModel) -> Vec<FirstEditable> {
let mut data_row = 0usize;
let mut found = Vec::new();
for (visual_row, row) in model.rows.iter().enumerate() {
let RenderRow::Data { cells, .. } = row else {
continue;
};
if let Some(col) = cells.iter().position(|cell| cell.editable) {
found.push(FirstEditable {
visual_row,
data_row,
col,
});
}
data_row += 1;
}
found
}
fn first_editable(model: &RenderModel) -> FirstEditable {
editable_cells(model)
.into_iter()
.next()
.expect("a pivot of raw data must offer at least one settable cell")
}
@@ -110,28 +138,31 @@ 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);
let at = first_editable(&model);
assert_eq!(
(row, col),
(0, 0),
"with no category on the None axis every cell is a stored cell"
(at.data_row, at.visual_row, at.col),
(0, 0, 0),
"with no category on the None axis every cell is a stored cell, and \
this fixture has no group headers, so the two row coordinates \
coincide here — see a_grouped_row_axis_does_not_shift_the_write"
);
let frame = client
.typed_cell(row, col, "7500")
.typed_cell(at.data_row, at.col, "7500")
.expect("an editable cell must compose a write");
let frames = send(&mut host, id, &frame);
@@ -161,13 +192,98 @@ 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[at.visual_row].cells()[at.col].text,
"7,500",
"the page redraws from the server's formatting, never from the \
\"7500\" the user typed"
);
}
/// A group header is a **row of the render model and not a row of the
/// grid**, and a write composed against the wrong one lands on a different
/// record — silently, because both are `usize`.
///
/// This is the fixture the round trip above cannot be: a `Coastal` header
/// sits *between* the two rows, so for the second row `visual_row` and
/// `data_row` differ. Injecting the confusion — passing `at.visual_row` to
/// `typed_cell` — leaves `East` unwritten.
#[test]
fn a_grouped_row_axis_does_not_shift_the_write() {
let mut host = {
let mut wb = Workbook::new("Grouped");
wb.add_category("Region").unwrap();
// West first and ungrouped, so the `Coastal` header falls *between*
// the two rows. A header before the first row would not do: the
// frame drops it (`AxisFrame::body_from`), and the two coordinates
// would coincide again.
wb.add_item("Region", "West").unwrap();
wb.add_item_in_group("Region", "East", "Coastal").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
for region in ["East", "West"] {
wb.model.set_cell(
CellKey::new(vec![
("Region".into(), region.into()),
("_Measure".into(), "Revenue".into()),
]),
CellValue::Number(1.0),
);
}
let mut app = App::new(wb, None);
app.rebuild_layout();
Host::new(app)
};
let mut client = Client::new();
let (id, snapshot) = host.connect(10, 10);
deliver(&mut client, &[snapshot]);
let model = client.model();
let at = editable_cells(&model)
.pop()
.expect("both rows are stored cells");
assert_eq!(
(at.visual_row, at.data_row),
(2, 1),
"precondition: the Coastal header sits between the two rows, so the \
last row is visual 2 and grid 1 — without this divergence the two \
coordinates coincide and nothing below is being tested"
);
let frame = client
.typed_cell(at.data_row, at.col, "4200")
.expect("an editable cell must compose a write");
send(&mut host, id, &frame);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&CellKey::new(vec![
("Region".into(), "East".into()),
("_Measure".into(), "Revenue".into()),
])),
Some(&CellValue::Number(4200.0)),
"the write must land on grid row 1, East"
);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&CellKey::new(vec![
("Region".into(), "West".into()),
("_Measure".into(), "Revenue".into()),
])),
Some(&CellValue::Number(1.0)),
"and must not have shifted: passing the VISUAL index writes nothing \
at all here (row 2 is off the grid), and in a document with one \
more row it would silently write the wrong one"
);
}
/// The other half of ADR 002, end to end: a cell the server projected as an
/// **aggregate** is rendered non-editable, so the page never offers an
/// editor `SetCell` could not be composed for. `Drill` is not on the wire
@@ -201,7 +317,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,7 +350,7 @@ 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"]);
}
/// improvise-955n: **the one real producer of a multi-command frame must
@@ -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
@@ -363,8 +363,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,
+99 -189
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,53 @@ 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();
// Both header tables and the column groups are built once per frame.
// The per-position accessors scan the frame from the start, so
// asking them inside the draw loops is quadratic in the grid and, at
// a deep scroll offset, grows with the offset.
let row_labels = rows.header_labels(row_offset);
let col_groups = cols.groups();
// The selected column's ancestry, per level, for every column — the
// underline below. Asking `same_prefix` per (column, level) scans
// the frame twice each time.
let col_ancestry = cols.shares_ancestry(sel_col);
// Transposed to level-major, because the column header is drawn one
// line per level. `header_labels` answers per column.
let col_lines: Vec<Vec<String>> = {
let per_column = cols.header_labels(col_offset);
(0..cols.levels())
.map(|level| {
per_column
.iter()
.map(|levels| levels[level].clone())
.collect()
})
.collect()
};
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 +164,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 +184,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 = col_groups.get(ci).copied().flatten();
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(
@@ -236,7 +209,7 @@ impl<'a> GridWidget<'a> {
let header_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
for d in 0..n_col_levels {
for (d, line) in col_lines.iter().enumerate() {
buf.set_string(
area.x,
y,
@@ -249,25 +222,15 @@ 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 = line[ci].clone();
// 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 = col_ancestry
.get(ci)
.and_then(|levels| levels.get(d))
.copied()
.unwrap_or(false);
let styled = if in_sel_group {
header_style.add_modifier(Modifier::UNDERLINED)
} else {
@@ -301,21 +264,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 +295,7 @@ impl<'a> GridWidget<'a> {
);
}
}
AxisEntry::DataItem(_) => {
FrameEntry::Item(_) => {
let ri = data_row_idx;
data_row_idx += 1;
@@ -369,24 +324,16 @@ 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 = row_labels[ri][d].clone();
buf.set_string(
hx,
y,
format!("{:<width$}", truncate(&label, sw), width = sw),
row_style,
);
hx += sub_widths[d];
hx += width;
}
for ci in visible_col_range.clone() {
@@ -568,13 +515,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 +550,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 +588,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,55 +914,28 @@ 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
/// headers and a total row — every part of the frame the widget draws,
/// in one buffer.
/// A grid with a row group, a column group, a page axis and a total row.
///
/// Written against the pre-migration path (improvise-n10) so the move to
/// `GridCache` is provably behaviour-preserving:
/// `doctrine(characterization_survives_the_move)`. It asserts the exact
/// buffer text, not a `contains`, because the interesting facts here —
/// repeat suppression, group indicator placement, column alignment,
/// adaptive row-header width — are all *positional* and a `contains`
/// cannot see any of them.
/// buffer text, not a `contains`, because the facts it *does* cover —
/// group indicator placement, column alignment, adaptive row-header
/// width, the page bar, the totals strip — are all *positional* and a
/// `contains` cannot see any of them. The fixture and `GROUPED_FRAME`
/// are byte-identical to the pre-improvise-anr9 versions, which is what
/// makes the comparison mean anything.
///
/// **What it does not cover, stated because an overclaim here is worse
/// than a gap**: `Region` is alone on Row and `Month` alone on Column, so
/// **both axes are single-level** and this exercises no multi-level
/// header and no repeat suppression — which is the whole subject of
/// improvise-anr9. Widening the fixture would destroy the only
/// byte-exact before/after evidence in that change, so it stays as it
/// is; the multi-level agreement is `tests/grid_frame_agreement.rs`,
/// which drives both front ends over a two-level, grouped fixture.
fn grouped_model() -> Workbook {
let mut m = Workbook::new("Test");
m.add_category("Region").unwrap(); // → Row
@@ -1063,13 +982,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
}
}
+422
View File
@@ -0,0 +1,422 @@
//! The two front ends draw the same grid frame (improvise-anr9/s1xw).
//!
//! The divergence this exists to rule out was real and shipped:
//! `GridCache::row_labels` was a `Vec<String>` built by
//! `GridLayout::row_label`, which **slash-joins a multi-level tuple** — the
//! exact rendering `src/ui/grid.rs::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 the browser drew `Food/Alice` where the
//! TUI drew two header columns, with no groups and no collapse indicators.
//!
//! The shape of the check matters. Each front end is driven the way it is
//! really driven — the widget into a ratatui buffer, the client through a
//! `Snapshot` of the same cache's `seed()` — and the two are compared on
//! what they *draw*, not on what they were handed. Comparing the caches
//! would be vacuous: it is one cache.
//!
//! Injected-fault record: re-introducing the slash-join on either side —
//! `labels: vec![rows.item(r).join("/")]` in the client's `render_model`, or
//! `rows.item(ri).join("/")` in the widget's row header — makes
//! `the_two_front_ends_draw_the_same_row_header` fail, naming the row and
//! the two spellings. Watched both fail before keeping it.
use improvise::model::cell::{CellKey, CellValue};
use improvise::ui::app::{App, AppMode, ViewCursor};
use improvise::ui::grid::GridWidget;
use improvise::view::{Axis, GridLayout};
use improvise::workbook::Workbook;
use improvise_command::ui::project;
use improvise_protocol::{GridCache, Snapshot, ViewState};
use improvise_wasm_client::render::{RenderModel, RenderRow};
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
fn coord(pairs: &[(&str, &str)]) -> CellKey {
CellKey::new(
pairs
.iter()
.map(|(c, i)| (c.to_string(), i.to_string()))
.collect(),
)
}
/// Both axes multi-level and grouped — the cross product the slash-join used
/// to flatten, on rows *and* columns, so the frame carries header levels,
/// repeat suppression, group spans and a collapse state in one fixture.
///
/// Two deliberate asymmetries. `Carol` sits outside the `Family` group and
/// *before* it, so collapsing `Family` still leaves a row: an all-collapsed
/// axis draws no group headers at all, in both front ends and before this
/// change (see the hand-back), and a fixture that hit it would be testing
/// that bug. And `Month` has one grouped quarter and one ungrouped month, so
/// the column runs are not uniform — a `col_groups` that emitted one span
/// per column, or one span for the whole axis, would pass a uniform fixture.
fn two_level_rows() -> Workbook {
let mut m = Workbook::new("Test");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.add_category("Recipient").unwrap();
m.add_item("Type", "Food").unwrap();
m.add_item("Type", "Clothing").unwrap();
m.add_item_in_group("Month", "Jan", "Q1").unwrap();
m.add_item_in_group("Month", "Feb", "Q1").unwrap();
m.add_item("Month", "Sep").unwrap();
m.add_item("Recipient", "Carol").unwrap();
m.add_item_in_group("Recipient", "Alice", "Family").unwrap();
m.add_item_in_group("Recipient", "Bob", "Family").unwrap();
m.active_view_mut().set_axis("Recipient", Axis::Row);
m.add_item("_Measure", "Revenue").unwrap();
m.add_item("_Measure", "Cost").unwrap();
m.active_view_mut().set_axis("_Measure", Axis::Column);
for t in ["Food", "Clothing"] {
for r in ["Carol", "Alice", "Bob"] {
for mo in ["Jan", "Feb", "Sep"] {
for meas in ["Revenue", "Cost"] {
m.model.set_cell(
coord(&[
("Type", t),
("Month", mo),
("Recipient", r),
("_Measure", meas),
]),
CellValue::Number(1.0),
);
}
}
}
}
m
}
/// The one cache both ends are driven from — built exactly as the TUI builds
/// it, over the whole grid at offsets zero (gotcha 44).
fn whole_grid(wb: &mut Workbook) -> (App, GridCache) {
let none_cats = wb.active_view().none_cats();
wb.model.recompute_formulas(&none_cats);
let app = App::new(wb.clone(), None);
let cache = project::grid_of(
&app,
project::Viewport {
row_offset: 0,
col_offset: 0,
rows: app.layout.row_count(),
cols: app.layout.col_count(),
},
);
(app, cache)
}
/// The row-header text the TUI *paints*, one entry per drawn row, each split
/// into its level sub-columns and trimmed.
///
/// It slices by `AxisFrame::header_widths` because that is the same rule the
/// widget lays the sub-columns out with (improvise-1125) — reading the
/// widths a second way here would be the very duplication under test.
fn tui_row_headers(wb: &Workbook, cache: &GridCache) -> Vec<Vec<String>> {
let lines = tui_lines(wb, cache);
let sep = header_rule(&lines);
let widths = cache.row_frame.header_widths();
lines[sep + 1..]
.iter()
// The body ends at the blank space below the grid, or at the rule
// above the totals strip — which is not a row of the frame.
.take_while(|l| !l.trim().is_empty() && !l.trim_end().chars().all(|c| c == '─'))
.map(|line| {
let chars: Vec<char> = line.chars().collect();
let mut at = 0usize;
widths
.iter()
.map(|w| {
let end = (at + *w as usize).min(chars.len());
let cell: String = chars[at.min(chars.len())..end].iter().collect();
at = end;
cell.trim_end().to_string()
})
.collect()
})
.collect()
}
/// One painted header line, cut into its columns and trimmed.
///
/// It cuts by the cache's own `col_widths` past the row header, because that
/// is the geometry the widget lays the columns out with — measuring a second
/// way here would be the duplication under test. Labels are right-aligned
/// into `width - 1`, so a trim recovers them.
fn tui_header_cells(line: &str, cache: &GridCache) -> Vec<String> {
let chars: Vec<char> = line.chars().collect();
let mut at: usize = cache
.row_frame
.header_widths()
.iter()
.map(|w| *w as usize)
.sum();
cache
.col_widths
.iter()
.map(|w| {
let end = (at + *w as usize).min(chars.len());
let cell: String = chars[at.min(chars.len())..end].iter().collect();
at = end;
cell.trim().to_string()
})
.collect()
}
/// The index of the rule under the column headers: the one line that is
/// nothing else.
fn header_rule(lines: &[String]) -> usize {
lines
.iter()
.position(|l| !l.trim().is_empty() && l.chars().all(|c| c == '─'))
.expect("the header separator")
}
/// The buffer the widget paints, one string per line, borders stripped.
fn tui_lines(wb: &Workbook, cache: &GridCache) -> Vec<String> {
let area = Rect::new(0, 0, 100, 30);
let mut buf = Buffer::empty(area);
let layout = GridLayout::new(&wb.model, wb.active_view());
let view_name = wb.active_view.clone();
let buffers = std::collections::HashMap::new();
GridWidget::new(
cache,
ViewCursor::default(),
&view_name,
&layout,
&AppMode::Normal,
"",
&buffers,
None,
)
.render(area, &mut buf);
let width = area.width as usize;
buf.content()
.chunks(width)
// Strip the block's left and right border columns.
.map(|row| {
row[1..width - 1]
.iter()
.map(|c| c.symbol())
.collect::<String>()
})
.collect()
}
/// What the browser client decides to draw, from a `Snapshot` of the same
/// cache — the real bootstrap path, not a hand-built client state.
fn browser_rows(cache: &GridCache) -> Vec<RenderRow> {
browser_model(cache).rows
}
fn browser_model(cache: &GridCache) -> RenderModel {
let mut client = improvise_wasm_client::init();
client.bootstrap(Snapshot {
view: ViewState::default(),
seed: cache
.seed()
.into_iter()
.map(improvise_protocol::Projection::Grid)
.collect(),
});
client.model()
}
#[test]
fn the_two_front_ends_draw_the_same_row_header() {
let mut wb = two_level_rows();
let (_app, cache) = whole_grid(&mut wb);
let tui = tui_row_headers(&wb, &cache);
let browser = browser_rows(&cache);
assert_eq!(
tui.len(),
browser.len(),
"the two ends drew a different number of rows\nTUI: {tui:?}\nbrowser: {browser:?}"
);
assert!(
tui.len() > 4,
"precondition: the fixture must have group headers AND several data \
rows, or nothing here is being compared: {tui:?}"
);
for (i, (painted, decided)) in tui.iter().zip(browser.iter()).enumerate() {
// A group header is one label across the whole row header; a data
// row is one label per level. The TUI's slicing puts a group label
// in the first sub-column, so compare the joined non-empty text.
let painted_text = painted.join("").trim().to_string();
match decided {
// The full painted string, glyph and name: a branch that checked
// only the glyph would pass on any group name whatsoever.
RenderRow::Group { label, collapsed } => assert_eq!(
painted_text,
format!("{} {label}", indicator(*collapsed)),
"row {i}: the two ends disagree about the group header"
),
RenderRow::Data { labels, .. } => assert_eq!(
painted, labels,
"row {i}: the TUI painted {painted:?} and the browser decided \
{labels:?} — one of them re-derived the frame"
),
}
}
}
/// The TUI's glyph, spelled here so the comparison above is against what is
/// really painted rather than against a prefix test.
fn indicator(collapsed: bool) -> &'static str {
match collapsed {
true => "▶",
false => "▼",
}
}
/// The column side of the same agreement, and it is a separate test because
/// `the_two_front_ends_draw_the_same_row_header` drives **one arm**: a
/// `col_levels` that stopped suppressing repeats, or a `col_groups` that
/// emitted one span per column, would sit green behind it
/// (`doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)`).
#[test]
fn the_two_front_ends_draw_the_same_column_header() {
let mut wb = two_level_rows();
let (_app, cache) = whole_grid(&mut wb);
let model = browser_model(&cache);
let lines = tui_lines(&wb, &cache);
let rule = header_rule(&lines);
// The header block is the group line (when there is one) plus one line
// per level, ending at the rule.
let levels = model.col_levels.len();
assert_eq!(levels, 2, "precondition: Month × _Measure is two levels");
assert!(
!model.col_groups.is_empty(),
"precondition: Q1 groups two of the three months"
);
// Each header line, cut into its columns and compared *exactly* to what
// the browser decided. A `contains` would be one-directional: it cannot
// see a label the browser wrongly blanked (the filter would skip it) and
// it cannot see one painted under the wrong column.
for (level, labels) in model.col_levels.iter().enumerate() {
assert_eq!(
&tui_header_cells(&lines[rule - levels + level], &cache),
labels,
"level {level}: the TUI's header columns and the browser's \
{labels:?} disagree — on suppression, on placement, or on both"
);
}
// The spans partition the columns, and they are not all one column wide
// — the fixture has a grouped run of two and an ungrouped run of one.
let total: usize = model.col_groups.iter().map(|g| g.span).sum();
assert_eq!(
total,
cache.col_frame.len(),
"the spans must cover every column, or a colspan layout leaves a hole"
);
let named: Vec<(Option<&str>, usize)> = model
.col_groups
.iter()
.map(|g| (g.label.as_deref(), g.span))
.collect();
assert_eq!(
named,
vec![(Some("Q1"), 6)],
"Q1 covers all six columns, INCLUDING the two under the ungrouped \
Sep — `expand_category` emits a header on entering a group and no \
boundary on leaving one, so the nearest-preceding walk keeps \
answering Q1 (see `AxisFrame::group_of`). That is `GridLayout`'s \
rule and the TUI paints the same span; it is reproduced here rather \
than corrected, because correcting it changes what the TUI draws. \
See `THE_MEMBERSHIP_CORRECT_SPANS` for what improvise-gd83 makes \
this line say."
);
// Spelled out rather than guessed, because a wrong future expectation is
// worse than none: whoever picks up improvise-gd83 will read this and
// believe it. The frame here is NOT one Q1 run — `_Measure` is the outer
// level, so the column axis is:
//
// col_cats = ["_Measure", "Month"]
// Group(Q1) [Revenue,Jan] [Revenue,Feb] [Revenue,Sep]
// Group(Q1) [Cost,Jan] [Cost,Feb] [Cost,Sep]
//
// Two Q1 headers, one per measure, and the ungrouped `Sep` sits *inside*
// each run rather than after both. So membership-correct spans are four
// runs, not two — and they do not coalesce across the second header,
// because `Sep` breaks the run between them.
const THE_MEMBERSHIP_CORRECT_SPANS: [(Option<&str>, usize); 4] =
[(Some("Q1"), 2), (None, 1), (Some("Q1"), 2), (None, 1)];
assert_ne!(
named.as_slice(),
THE_MEMBERSHIP_CORRECT_SPANS.as_slice(),
"when this stops differing, improvise-gd83 is fixed and the \
assertion above should become the constant"
);
// And the group line the TUI paints names the same group.
let group_line = &lines[rule - levels - 1];
assert!(
group_line.contains("▼ Q1"),
"the TUI paints the same group above the same columns: {group_line:?}"
);
}
/// The joined form appears in neither, which is the bug's own words. It is a
/// separate assertion because the agreement above would also hold if *both*
/// ends started slash-joining.
#[test]
fn neither_front_end_produces_a_slash_joined_label() {
let mut wb = two_level_rows();
let (_app, cache) = whole_grid(&mut wb);
for row in browser_rows(&cache) {
for label in row.labels() {
assert!(
!label.contains('/'),
"the browser drew the joined label {label:?}"
);
}
}
for painted in tui_row_headers(&wb, &cache) {
for label in painted {
assert!(
!label.contains('/'),
"the TUI painted the joined label {label:?}"
);
}
}
}
/// The group's collapse state reaches the browser as a *fact*, not as a
/// glyph. The TUI spells it ▼/▶ (presentation, gotcha 26); the client gets
/// the boolean and spells it however the DOM wants. Both states are checked,
/// because a `collapsed` hard-wired to `false` would pass the first half.
#[test]
fn the_collapse_state_crosses_as_a_fact_not_a_glyph() {
for collapse in [false, true] {
let mut wb = two_level_rows();
if collapse {
wb.active_view_mut()
.toggle_group_collapse("Recipient", "Family");
}
let (_app, cache) = whole_grid(&mut wb);
let groups: Vec<(String, bool)> = browser_rows(&cache)
.into_iter()
.filter_map(|r| match r {
RenderRow::Group { label, collapsed } => Some((label, collapsed)),
RenderRow::Data { .. } => None,
})
.collect();
assert!(
!groups.is_empty(),
"precondition (collapse={collapse}): a collapsed group still owes \
the user a header to expand again"
);
for (label, collapsed) in groups {
assert_eq!(label, "Family");
assert_eq!(collapsed, collapse, "the frame states the view's answer");
}
}
}
+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");
+119 -17
View File
@@ -108,12 +108,27 @@ function setShellStatus(text) {
paintStatus();
}
/** The extent this window can show, in whole cells. */
// What the grid's own frame costs, in whole cells, as of the last repaint.
// One header row and one header column was right until improvise-anr9: the
// column header is now one row PER LEVEL plus an optional group row, the row
// header is `row_levels` columns wide, and inline group headers take a row
// each. Reserving one of each announces a viewport the page cannot show, and
// `#grid` has `overflow: auto`, so the DOM would acquire a scroll position of
// its own while the client's cursor offsets were computed from the larger
// window — putting the selected cell off screen.
//
// It is measured off the painted model rather than predicted, and defaults to
// the old one-and-one before the first paint, which is the only moment there
// is nothing to measure. What it counts, and the one thing it deliberately
// does not, is at the tail of `repaint`.
let frameCost = { rows: 1, cols: 1 };
/** The extent this window can show, in whole DATA cells. */
function extent() {
const box = el.grid.getBoundingClientRect();
return {
rows: Math.max(1, Math.floor(box.height / ROW_PX) - 1),
cols: Math.max(1, Math.floor(box.width / COL_PX) - 1),
rows: Math.max(1, Math.floor(box.height / ROW_PX) - frameCost.rows),
cols: Math.max(1, Math.floor(box.width / COL_PX) - frameCost.cols),
};
}
@@ -121,7 +136,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
@@ -271,26 +286,106 @@ function repaint() {
el.viewName.textContent = model.view_name;
el.grid.replaceChildren(gridTable(model));
el.categories.replaceChildren(categoryList(model));
// What the frame costs, so the NEXT viewport announcement is honest: the
// header lines, a group line if there is one, the row header's width in
// columns, and every inline group row.
//
// Inline group ROWS are NOT reserved, and that is a decision rather than
// an oversight. They take real height, but how many are in view depends on
// how many rows the window asked for, so every way of counting them is
// either a feedback loop (count the current ones: a smaller window crosses
// fewer groups and asks for a larger one) or a ratchet (keep the maximum:
// a group-dense grid stores an oversized cost on its first oversized
// projection and never lets it fall — one group header per row clamps the
// grid to a single row, permanently). Both were tried on this PR.
//
// What is left is an over-request bounded by the groups in view. The cost
// is that a grouped grid asks for a few rows more than the table shows,
// which matters only for a selection near the bottom edge — and that gap
// is not new and not specific to groups: nothing in this page calls
// `scrollIntoView` at all, so a selection the client has not yet scrolled
// to can already sit below the fold. Filed as its own ticket rather than
// answered with a heuristic in a file no test in this repo can execute.
const next = {
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
cols: model.row_levels,
};
const changed = next.rows !== frameCost.rows || next.cols !== frameCost.cols;
frameCost = next;
if (changed) sendViewport();
}
/** 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;
// `label` is null for a run in no group. An empty STRING is a legal
// group name and still gets its indicator, which is why this is a null
// check and not a truthiness test.
th.textContent =
group.label === null ? "" : 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;
// `r` is one binding shared by every closure below, so a click handler
// that captured it would fire with the LAST row's index. Snapshot it.
const dataRow = r;
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();
@@ -310,12 +405,19 @@ function gridTable(model) {
// cell. An aggregate is a drill target and drill is not on the wire
// yet, so it is simply not offered here.
td.classList.toggle("editable", cell.editable);
if (cell.editable) td.addEventListener("click", () => edit(td, r, c, cell.edit_text));
if (cell.editable)
td.addEventListener("click", () => edit(td, dataRow, 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;
}