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