Merge pull request #23 from fiddlerwoaroof/wave/a-grid-frame
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
WU-A: the grid frame on the wire (improvise-anr9, improvise-s1xw absorbed, improvise-1125). row_labels/col_labels become one AxisFrame per axis carrying the five facts gotcha 44 enumerated, so the browser draws multi-level headers, repeat suppression, group spans and collapse indicators instead of Food/Alice. Includes the user's P1: a cell's identity is cats zipped with its tuple, and invalidate_relabelled compared tuples alone — so an axis swap left every CellDisplay and column total standing under a coordinate that no longer named them, and because grid_updates diffs against the framed mirror, the producer then sent nothing at all. A client wrong forever.
This commit is contained in:
Generated
+3
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user