feat(tui,wasm,io): the grid, page bar, item rows and CSV export draw displays (improvise-cq1q.12)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (adr007-display)
2026-10-08 21:46:12 -07:00
committed by Edward Langley
parent 386b693bf5
commit 7a4dc07642
6 changed files with 219 additions and 20 deletions
+173
View File
@@ -1902,6 +1902,179 @@ mod tests {
assert_ne!(summary.name, "Overview");
}
/// A category, an item, a group, a page category, a measure and two views,
/// each shown under a name that is not its key (improvise-cq1q.12).
fn app_shown_otherwise() -> App {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
wb.add_category("Quarter").unwrap();
wb.add_item_in_group("Region", "East", "Coastal").unwrap();
wb.add_item("Region", "West").unwrap();
wb.add_item("Quarter", "Q1").unwrap();
wb.add_item("Quarter", "Q2").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);
wb.active_view_mut().set_axis("Quarter", Axis::Page);
wb.active_view_mut().set_expand_depth("Region", 1);
for region in ["East", "West"] {
for quarter in ["Q1", "Q2"] {
wb.model.set_cell(
CellKey::new(vec![
("Region".into(), region.into()),
("Quarter".into(), quarter.into()),
("_Measure".into(), "Revenue".into()),
]),
CellValue::Number(1.0),
);
}
}
wb.create_view("Plan").set_axis("Region", Axis::Column);
wb.active_view_mut().set_display("Overview");
wb.views.get_mut("Plan").unwrap().set_display("Budget");
let region = wb.model.category_mut("Region").unwrap();
region.set_display("Sales Area");
region.set_item_display("East", "Eastern Seaboard");
region.set_group_display("Coastal", "Shoreline");
let quarter = wb.model.category_mut("Quarter").unwrap();
quarter.set_display("Period");
quarter.set_item_display("Q1", "First");
quarter.set_item_display("Q2", "Second");
wb.model
.category_mut("_Measure")
.unwrap()
.set_item_display("Revenue", "Sales");
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
const ALL: Viewport = Viewport {
row_offset: 0,
col_offset: 0,
rows: 20,
cols: 20,
};
#[test]
fn the_frame_shows_displays_and_its_key_tuples_still_address_the_cells() {
let app = app_shown_otherwise();
let grid = grid_of(&app, ALL);
assert_eq!(grid.row_frame.cat_displays, vec!["Sales Area"]);
assert_eq!(grid.row_frame.cats, vec!["Region"], "the key stays beside it");
assert_eq!(grid.col_frame.header_labels(0), vec![vec!["Sales".to_string()]]);
let header = grid
.row_frame
.entries
.iter()
.find_map(|e| match e {
FrameEntry::Group(g) => Some(g),
_ => None,
})
.expect("East sits under Coastal");
assert_eq!((header.name.as_str(), header.display.as_str()), ("Coastal", "Shoreline"));
assert_eq!(
grid.row_frame.header_labels(0)[0],
vec!["Eastern Seaboard".to_string()]
);
// The frame's KEY tuples, joined with the page bar, are the cell's address.
let page: Vec<(String, String)> = grid
.page_coords
.iter()
.map(|p| (p.cat.clone(), p.selected.clone()))
.collect();
for (r, row) in grid.row_frame.items().enumerate() {
for (c, col) in grid.col_frame.items().enumerate() {
let coords = grid
.row_frame
.cats
.iter()
.cloned()
.zip(row.iter().cloned())
.chain(grid.col_frame.cats.iter().cloned().zip(col.iter().cloned()))
.chain(page.iter().cloned())
.collect();
assert_eq!(
Some(CellKey::new(coords)),
app.layout.cell_key(r, c),
"({r},{c}) must be addressed by keys"
);
}
}
}
#[test]
fn the_page_bar_carries_displays_beside_the_keys_a_page_command_names() {
let app = app_shown_otherwise();
let bar = grid_of(&app, ALL).page_coords;
let quarter = &bar[0];
assert_eq!((quarter.cat.as_str(), quarter.selected.as_str()), ("Quarter", "Q1"));
assert_eq!(quarter.cat_display, "Period");
assert_eq!(quarter.selected_display, "First");
assert_eq!(quarter.items.as_deref(), Some(&["Q1".to_string(), "Q2".into()][..]));
assert_eq!(
quarter.item_displays.as_deref(),
Some(&["First".to_string(), "Second".into()][..])
);
}
#[test]
fn an_item_row_shows_its_display_and_keeps_its_key() {
let app = app_shown_otherwise();
let rows = category_rows(&app.model_state.workbook.model, app.view());
let region = rows.iter().find(|r| r.name == "Region").unwrap();
let east = region.items.iter().find(|i| i.name == "East").unwrap();
assert_eq!(east.display, "Eastern Seaboard");
let west = region.items.iter().find(|i| i.name == "West").unwrap();
assert_eq!(west.display, "West", "an unrenamed item shows its key");
let measure = rows.iter().find(|r| r.name == "_Measure").unwrap();
assert_eq!(measure.items[0].display, "Sales");
}
#[test]
fn each_view_summary_shows_its_own_display_and_its_categories_displays() {
let app = app_shown_otherwise();
let summaries = view_summaries(&app);
let by_key = |key: &str| summaries.iter().find(|s| s.name == key).unwrap();
assert_eq!(by_key("Default").display, "Overview");
assert_eq!(by_key("Plan").display, "Budget", "not the active view's");
let plan_region = by_key("Plan")
.axes
.iter()
.find(|a| a.category == "Region")
.unwrap();
assert_eq!(plan_region.display, "Sales Area");
assert_eq!(plan_region.axis, Axis::Column);
let line = improvise_protocol::view_axis_summary(by_key("Plan"));
assert_eq!(line, "C:Sales Area,Period", "the line draws displays, not keys");
}
#[test]
fn a_records_coordinate_shows_its_display_and_edits_its_key() {
let mut app = app_shown_otherwise();
let view = app.model_state.workbook.active_view_mut();
view.set_axis("_Index", Axis::Row);
view.set_axis("_Dim", Axis::Column);
app.rebuild_layout();
let grid = grid_of(&app, ALL);
assert!(grid.records);
let col = grid
.col_frame
.items()
.position(|t| t == &vec!["Region".to_string()])
.expect("a column per category, found by its key");
assert_eq!(grid.col_frame.header_labels(0)[col], vec!["Sales Area".to_string()]);
let shown: Vec<&str> = grid.cells.iter().map(|r| r[col].text.as_str()).collect();
assert!(shown.contains(&"Eastern Seaboard"), "{shown:?}");
let east = grid
.cells
.iter()
.map(|r| &r[col])
.find(|c| c.text == "Eastern Seaboard")
.unwrap();
assert_eq!(east.edit_text.as_deref(), Some("East"), "the editor seeds the key");
}
/// A pivot-shaped default view plus a records-shaped second one, so a
/// switch between them crosses the boundary `settled_mode` watches.
fn app_with_a_records_shaped_second_view() -> App {
@@ -41,11 +41,11 @@ pub fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
.from_writer(Vec::new());
// Header row
let row_header = layout.row_cats.join("/");
let row_header = layout.row_cat_displays(model).join("/");
let page_label: Vec<String> = layout
.page_coords
.iter()
.map(|p| format!("{}={}", p.cat, p.selected))
.map(|p| format!("{}={}", p.cat_display, p.selected_display))
.collect();
let header_prefix = if page_label.is_empty() {
row_header
@@ -56,13 +56,13 @@ pub fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
if !header_prefix.is_empty() {
header.push(header_prefix);
}
header.extend((0..layout.col_count()).map(|ci| layout.col_label(ci)));
header.extend((0..layout.col_count()).map(|ci| layout.col_display_label(ci)));
wtr.write_record(&header)?;
// Data rows
for ri in 0..layout.row_count() {
let mut record: Vec<String> = Vec::new();
let row_label = layout.row_label(ri);
let row_label = layout.row_display_label(ri);
if !row_label.is_empty() {
record.push(row_label);
}
+10 -7
View File
@@ -259,7 +259,7 @@ pub fn render_model(state: &ClientState) -> RenderModel {
let row = match entry {
FrameEntry::GroupEnd { .. } => return Some(None),
FrameEntry::Group(group) => RenderRow::Group {
label: group.name.clone(),
label: group.display.clone(),
collapsed: group.collapsed,
},
FrameEntry::Item { .. } => {
@@ -340,12 +340,14 @@ fn page_axes(state: &ClientState) -> Vec<RenderPageAxis> {
.page_coords
.iter()
.map(|axis| RenderPageAxis {
cat: axis.cat.clone(),
selected: axis.selected.clone(),
cat: axis.cat_display.clone(),
selected: axis.selected_display.clone(),
cycle: axis.cycle().map(|(items, at)| RenderPageCycle {
at,
count: items.len(),
items: items.to_vec(),
// The menu shows displays; a pick travels as the index `at` and
// `items` order name, so no key is read back from here.
items: axis.item_displays.clone().unwrap_or_default(),
}),
})
.collect()
@@ -481,8 +483,8 @@ impl RenderCatRow {
expanded: Some(*expanded),
selected,
},
CatTreeEntry::Item { item_name, .. } => Self {
label: item_name.clone(),
CatTreeEntry::Item { display, .. } => Self {
label: display.clone(),
is_category: false,
axis: String::new(),
item_count: None,
@@ -544,7 +546,7 @@ fn col_groups(cols: &AxisFrame) -> Vec<RenderColGroup> {
true => runs
.into_iter()
.map(|(group, span)| RenderColGroup {
label: group.map(|g| g.name.clone()),
label: group.map(|g| g.display.clone()),
collapsed: group.is_some_and(|g| g.collapsed),
span,
})
@@ -594,6 +596,7 @@ mod tests {
level: 0,
depth: 0,
name: name.into(),
display: name.into(),
collapsed,
})
}