wip(protocol,command): AxisFrame, PageAxis, CategoryRow and ViewSummary carry displays (improvise-cq1q.12)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (adr007-display)
2026-10-08 21:44:11 -07:00
committed by Edward Langley
parent bde258adec
commit 386b693bf5
12 changed files with 283 additions and 83 deletions
@@ -777,6 +777,7 @@ impl Cmd for FilterToItem {
Some(CatTreeEntry::Item {
cat_name,
item_name,
..
}) => {
vec![
Box::new(effect::SetAxis {
@@ -821,6 +822,7 @@ impl Cmd for DeleteCategoryAtCursor {
Some(CatTreeEntry::Item {
cat_name,
item_name,
..
}) => {
let mut effects: Vec<Box<dyn Effect>> = vec![
Box::new(effect::RemoveItem {
+3 -1
View File
@@ -106,7 +106,9 @@ fn frame_of(layout: &crate::view::layout::GridLayout) -> u64 {
group_name.hash(&mut h);
collapsed.hash(&mut h);
}
crate::view::layout::AxisEntry::DataItem { tuple, collapsed } => {
crate::view::layout::AxisEntry::DataItem {
tuple, collapsed, ..
} => {
1u8.hash(&mut h);
tuple.hash(&mut h);
collapsed.hash(&mut h);
@@ -62,6 +62,7 @@ mod tests {
CatTreeEntry::Item {
cat_name,
item_name,
..
} if cat_name == MEASURE_CATEGORY => Some(item_name),
_ => None,
})
@@ -145,6 +146,7 @@ mod tests {
let item = CatTreeEntry::Item {
cat_name: "Region".into(),
item_name: "North".into(),
display: "North".into(),
};
assert_eq!(header.cat_name(), "Region");
assert_eq!(item.cat_name(), "Region");
+50 -19
View File
@@ -14,10 +14,10 @@ use improvise_core::workbook::Workbook;
use improvise_io::import::analyzer::{DateComponent, DateShape, FieldKind};
use improvise_io::import::wizard::{DuplicatePolicy, ImportWizard, WizardStep};
use improvise_protocol::{
AxisFrame, Basis, BasisCache, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay,
AxisFrame, Basis, BasisCache, CategoryItem, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay,
CellId, Command, CreateKind, DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup,
GridCache, GridExtent, GridUpdate, Projection, RecordCell, Rejection, Snapshot, TileBarCache,
TileEntry, ViewListCache, ViewState, ViewSummary, WidgetCache, WizardCache, WizardChoice,
TileEntry, ViewAxis, ViewListCache, ViewState, ViewSummary, WidgetCache, WizardCache, WizardChoice,
WizardCollision, WizardDateField, WizardFieldKind, WizardProposal, WizardStepKind, basis,
reduce_view,
};
@@ -157,8 +157,16 @@ pub fn grid_in(app: &App, view_name: &str, viewport: Viewport) -> GridCache {
};
GridCache {
row_frame: axis_frame(&layout.row_cats, &layout.row_items, rows.clone()),
col_frame: axis_frame(&layout.col_cats, &layout.col_items, cols.clone()),
row_frame: axis_frame(
(&layout.row_cats, layout.row_cat_displays(model)),
&layout.row_items,
rows.clone(),
),
col_frame: axis_frame(
(&layout.col_cats, layout.col_cat_displays(model)),
&layout.col_items,
cols.clone(),
),
col_widths: cols
.clone()
.map(|c| body.col_widths.get(c).copied().unwrap_or(0))
@@ -222,7 +230,11 @@ pub(crate) fn rederived_for(
/// One axis's frame, windowed to the data indices `window` names. Three edge rules,
/// all about group headers — see `context/code/command.md`.
fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usize>) -> AxisFrame {
fn axis_frame(
(cats, cat_displays): (&[String], Vec<String>),
items: &[AxisEntry],
window: std::ops::Range<usize>,
) -> AxisFrame {
let total = items
.iter()
.filter(|e| matches!(e, AxisEntry::DataItem { .. }))
@@ -242,6 +254,7 @@ fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usiz
level,
depth,
group_name,
group_display,
collapsed,
..
} => {
@@ -249,6 +262,7 @@ fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usiz
level: *level,
depth: *depth,
name: group_name.clone(),
display: group_display.clone(),
collapsed: *collapsed,
});
match entries.is_empty() {
@@ -266,7 +280,11 @@ fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usiz
depth: *depth,
}),
},
AxisEntry::DataItem { tuple, collapsed } => {
AxisEntry::DataItem {
tuple,
display,
collapsed,
} => {
let idx = data_idx;
data_idx += 1;
if window.contains(&idx) {
@@ -276,6 +294,7 @@ fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usiz
entries.append(&mut pending);
entries.push(FrameEntry::Item {
tuple: tuple.clone(),
display: display.clone(),
collapsed: *collapsed,
});
}
@@ -290,6 +309,7 @@ fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usiz
}
AxisFrame {
cats: cats.to_vec(),
cat_displays,
first: window.start,
entries,
}
@@ -330,8 +350,11 @@ fn cell_display(
) -> CellDisplay {
if layout.is_records_mode() {
// The value, not its spelling: this module used to infer numeric-ness
// with `parse::<f64>()`, which called a coordinate `2024` a number.
let text = value.as_ref().map(|v| v.to_string()).unwrap_or_default();
// with `parse::<f64>()`, which called a coordinate `2024` a number. A
// coordinate is drawn under its item's display; the editor keeps the key.
let text = layout
.coordinate_display(&app.model_state.workbook.model, row, col)
.unwrap_or_else(|| value.as_ref().map(|v| v.to_string()).unwrap_or_default());
return CellDisplay {
edit_text: Some(value.as_ref().map(CellValue::edit_text).unwrap_or_default()),
text,
@@ -436,11 +459,6 @@ fn non_item(category: &str, item: &str) -> CellId {
}
}
/// What a person sees for a category key; the key itself when no category holds it.
fn category_display<'a>(model: &'a Model, name: &'a str) -> &'a str {
model.category(name).map_or(name, |c| c.display())
}
/// The categories the category panel draws: **every** category, virtual ones
/// included, with their items and their axis (improvise-vjms, gotcha 7).
pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
@@ -449,8 +467,15 @@ pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
.into_iter()
.map(|name| CategoryRow {
name: name.to_string(),
display: category_display(model, name).to_string(),
items: model.effective_item_names(name),
display: model.category_display(name).to_string(),
items: model
.effective_item_names(name)
.into_iter()
.map(|item| CategoryItem {
display: model.item_display(name, &item).to_string(),
name: item,
})
.collect(),
// `try_axis_of`, not `axis_of`: a view that has never seen a category
// has no entry and `axis_of` panics. Unseen shows as `None`.
axis: view.try_axis_of(name).unwrap_or(Axis::None),
@@ -473,8 +498,8 @@ pub fn formula_entries(app: &App) -> Vec<FormulaEntry> {
/// Every view and where each category sits in it. Which view is *active* is
/// per-client and travels as `Command::SwitchView`, so it is not here.
pub fn view_summaries(app: &App) -> Vec<ViewSummary> {
app.model_state
.workbook
let workbook = &app.model_state.workbook;
workbook
.views
.iter()
.map(|(name, view)| ViewSummary {
@@ -483,7 +508,11 @@ pub fn view_summaries(app: &App) -> Vec<ViewSummary> {
axes: view
.category_axes
.iter()
.map(|(cat, axis)| (cat.clone(), *axis))
.map(|(cat, axis)| ViewAxis {
category: cat.clone(),
display: workbook.model.category_display(cat).to_string(),
axis: *axis,
})
.collect(),
})
.collect()
@@ -501,7 +530,7 @@ pub fn tiles_of(model: &Model, view: &View) -> Vec<TileEntry> {
.into_iter()
.map(|cat| TileEntry {
category: cat.to_string(),
display: category_display(model, cat).to_string(),
display: model.category_display(cat).to_string(),
axis: view.try_axis_of(cat).unwrap_or(Axis::None),
kind: model
.category(cat)
@@ -2481,6 +2510,7 @@ mod tests {
row_frame: AxisFrame {
first: 0,
cats: vec!["Region".into()],
cat_displays: vec!["Region".into()],
entries: vec![FrameEntry::item(vec!["North".into()])],
},
col_frame: AxisFrame::flat(["A", "B"]),
@@ -2493,6 +2523,7 @@ mod tests {
row_frame: AxisFrame {
first: 0,
cats: vec!["Department".into()],
cat_displays: vec!["Department".into()],
entries: mirror.row_frame.entries.clone(),
},
..mirror.clone()
+5 -3
View File
@@ -73,7 +73,9 @@ pub fn grid_body(
let (key, value) = cell_value(layout, &eval, records, ri, ci);
let fmt = plan.at(key.as_ref());
let text = match records {
true => value.as_ref().map(|v| v.to_string()).unwrap_or_default(),
true => layout.coordinate_display(model, ri, ci).unwrap_or_else(|| {
value.as_ref().map(|v| v.to_string()).unwrap_or_default()
}),
false => fmt.value(value.as_ref()),
};
widen(&mut widths, ci, text.width());
@@ -168,7 +170,7 @@ fn cell_value(
fn header_level_seed(layout: &GridLayout, n: usize) -> Vec<u16> {
let mut widths = vec![0u16; n];
let items = layout.col_items.iter().filter_map(|e| match e {
AxisEntry::DataItem { tuple, .. } => Some(tuple),
AxisEntry::DataItem { display, .. } => Some(display),
AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None,
});
for (ci, levels) in items.enumerate().take(n) {
@@ -197,7 +199,7 @@ pub fn compute_col_widths(model: &Model, layout: &GridLayout, plan: &FormatPlan)
/// keypress-path caller that borrows tuples — see `context/code/command.md`.
pub fn compute_row_header_width(layout: &GridLayout) -> u16 {
let items = layout.row_items.iter().filter_map(|e| match e {
AxisEntry::DataItem { tuple, .. } => Some(tuple),
AxisEntry::DataItem { display, .. } => Some(display),
AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None,
});
header_level_widths(items, layout.row_cats.len().max(1))
+1 -1
View File
@@ -531,7 +531,7 @@ mod tests {
live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::Row)],
axes: vec![crate::ViewAxis::plain("Region", Axis::Row)],
}])));
live.fold(&Command::SetSelected { row: 1, col: 1 });
+2 -2
View File
@@ -34,10 +34,10 @@ pub use reduce::{
records_named, reduce_view,
};
pub use render::{
AxisFrame, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, CreateKind,
AxisFrame, CategoryItem, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, CreateKind,
DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup, GridCache, GridExtent,
GridUpdate, MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache,
TileEntry, ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardChoice, WizardCollision,
TileEntry, ViewAxis, ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardChoice, WizardCollision,
WizardDateField, WizardFieldKind, WizardProposal, WizardStepKind, header_level_widths,
non_item_refusal, opening_tile,
};
+1 -1
View File
@@ -557,7 +557,7 @@ mod tests {
Command::Project(Projection::ViewList(vec![ViewSummary {
name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::Row)],
axes: vec![crate::ViewAxis::plain("Region", Axis::Row)],
}])),
Command::Project(Projection::TileBar(vec![TileEntry {
category: "Region".into(),
+184 -18
View File
@@ -217,7 +217,10 @@ pub struct FrameGroup {
/// The group's depth in its category's hierarchy, 0 for a root. Runs nest
/// by `(level, depth)`: `context/code/protocol.md` §3a (improvise-felo.6).
pub depth: usize,
/// The key; what `toggle-group` and a run lookup name.
pub name: String,
/// What the header shows (ADR 007); never an address.
pub display: String,
pub collapsed: bool,
}
@@ -235,16 +238,21 @@ pub enum FrameEntry {
/// One label per header level, outermost first. Data indices — the
/// `row`/`col` of every other field on [`GridCache`] — count only these.
Item {
/// The keys: the coordinate this position names, never replaced by `display`.
tuple: Vec<String>,
/// Parallel to `tuple`, one name per level: what the header shows (ADR 007).
display: Vec<String>,
/// The layout's own fold flag: `context/code/protocol.md` (improvise-4k1j).
collapsed: bool,
},
}
impl FrameEntry {
/// An ordinary, unfolded item, as `AxisEntry::item` is on the layout side.
/// An ordinary, unfolded item shown under its own keys, as
/// `AxisEntry::item` is on the layout side.
pub fn item(tuple: Vec<String>) -> Self {
FrameEntry::Item {
display: tuple.clone(),
tuple,
collapsed: false,
}
@@ -259,6 +267,8 @@ pub struct AxisFrame {
/// The categories on this axis, outermost first. Empty is a real layout
/// (one nameless row or column), not a gap — hence [`AxisFrame::levels`].
pub cats: Vec<String>,
/// Parallel to `cats`: what each level's category is called on screen.
pub cat_displays: Vec<String>,
/// The **absolute** data index of this frame's first item. Zero is a claim
/// and not an absence, so it is NOT `serde(default)` — §3a, improvise-1wl0.
pub first: usize,
@@ -270,6 +280,7 @@ pub struct AxisFrame {
#[derive(Deserialize)]
struct UncheckedAxisFrame {
cats: Vec<String>,
cat_displays: Vec<String>,
first: usize,
entries: Vec<FrameEntry>,
}
@@ -278,6 +289,22 @@ impl TryFrom<UncheckedAxisFrame> for AxisFrame {
type Error = String;
fn try_from(raw: UncheckedAxisFrame) -> Result<Self, String> {
if raw.cat_displays.len() != raw.cats.len() {
return Err(format!(
"{} category displays for {} categories",
raw.cat_displays.len(),
raw.cats.len()
));
}
let ragged = raw.entries.iter().find_map(|e| match e {
FrameEntry::Item { tuple, display, .. } if tuple.len() != display.len() => {
Some((tuple.len(), display.len()))
}
_ => None,
});
if let Some((keys, shown)) = ragged {
return Err(format!("{shown} displays for a tuple of {keys} keys"));
}
let beyond = raw
.entries
.iter()
@@ -294,6 +321,7 @@ impl TryFrom<UncheckedAxisFrame> for AxisFrame {
)),
None => Ok(Self {
cats: raw.cats,
cat_displays: raw.cat_displays,
first: raw.first,
entries: raw.entries,
}),
@@ -307,6 +335,7 @@ impl AxisFrame {
pub fn flat<S: Into<String>>(labels: impl IntoIterator<Item = S>) -> Self {
Self {
cats: Vec::new(),
cat_displays: Vec::new(),
first: 0,
entries: labels
.into_iter()
@@ -324,6 +353,14 @@ impl AxisFrame {
})
}
/// The display tuples, parallel to [`AxisFrame::items`]: what is drawn.
pub fn displays(&self) -> impl Iterator<Item = &Vec<String>> {
self.entries.iter().filter_map(|e| match e {
FrameEntry::Item { display, .. } => Some(display),
FrameEntry::Group(_) | FrameEntry::GroupEnd { .. } => None,
})
}
/// The innermost label of each data item. **Not "the labels"** — a draw
/// loop wants [`AxisFrame::header_labels`] and would draw these wrong.
pub fn leaf_labels(&self) -> Vec<String> {
@@ -391,12 +428,13 @@ impl AxisFrame {
/// **One pass, and that is the point** — what a draw loop wants (§3).
pub fn header_labels(&self, first_rendered: usize) -> Vec<Vec<String>> {
let items: Vec<&Vec<String>> = self.items().collect();
let shown: Vec<&Vec<String>> = self.displays().collect();
let levels = self.levels();
(0..items.len())
.map(|idx| {
(0..levels)
.map(|level| match shows(&items, idx, first_rendered, level) {
true => items[idx].get(level).cloned().unwrap_or_default(),
true => shown[idx].get(level).cloned().unwrap_or_default(),
false => String::new(),
})
.collect()
@@ -499,7 +537,7 @@ impl AxisFrame {
/// The width of each header level, in display cells. Derived rather than
/// projected, because the frame is the whole input (§3, improvise-1125).
pub fn header_widths(&self) -> Vec<u16> {
header_level_widths(self.items(), self.levels())
header_level_widths(self.displays(), self.levels())
}
}
@@ -617,7 +655,7 @@ impl GridCache {
if self.col_widths.len() < cols {
// Materialized once: `AxisFrame::item` scans, so asking it per
// column would make a re-fit quadratic in the column count.
let items: Vec<&Vec<String>> = self.col_frame.items().collect();
let items: Vec<&Vec<String>> = self.col_frame.displays().collect();
for levels in &items[self.col_widths.len()..cols] {
// Display cells, the unit `header_widths` and the
// authoritative `ColWidths` use — `chars().count()` is short.
@@ -761,10 +799,34 @@ pub struct CategoryRow {
pub name: String,
/// What the panel shows; `name` stays what a command names (ADR 007).
pub display: String,
pub items: Vec<String>,
pub items: Vec<CategoryItem>,
pub axis: Axis,
}
/// One item row: the key a command names, and what the panel shows (ADR 007).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CategoryItem {
pub name: String,
pub display: String,
}
impl CategoryItem {
/// An item shown under its own key.
pub fn plain(name: impl Into<String>) -> Self {
let name = name.into();
Self {
display: name.clone(),
name,
}
}
}
impl From<&str> for CategoryItem {
fn from(name: &str) -> Self {
Self::plain(name)
}
}
/// The category panel's categories, items and axes (improvise-pca) —
/// **every** category, and `_Measure`'s items are derived. See §3.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -786,7 +848,7 @@ pub enum CategoryTreeUpdate {
Categories(Vec<CategoryRow>),
Items {
category: String,
items: Vec<String>,
items: Vec<CategoryItem>,
},
/// Every category whose axis moved, in one update.
Axes(Vec<(String, Axis)>),
@@ -861,8 +923,29 @@ pub struct ViewSummary {
pub name: String,
/// What the view panel shows; `name` stays what a command names.
pub display: String,
/// `(category, axis)` for the categories this view displays.
pub axes: Vec<(String, Axis)>,
/// Where each category this view displays sits.
pub axes: Vec<ViewAxis>,
}
/// One category's place in a view: the key a command names, what the panel
/// shows for it, and the axis.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViewAxis {
pub category: String,
pub display: String,
pub axis: Axis,
}
impl ViewAxis {
/// A category shown under its own key.
pub fn plain(category: impl Into<String>, axis: Axis) -> Self {
let category = category.into();
Self {
display: category.clone(),
category,
axis,
}
}
}
/// The view panel's copy of the view list (improvise-pca). Which view is
@@ -1167,7 +1250,7 @@ mod tests {
CategoryRow {
name: name.into(),
display: name.into(),
items: items.iter().map(|s| (*s).to_string()).collect(),
items: items.iter().map(|s| CategoryItem::plain(*s)).collect(),
axis,
}
}
@@ -1328,6 +1411,7 @@ mod tests {
grid.apply(&GridUpdate::RowFrame(AxisFrame {
first: 0,
cats: vec!["Zone".into()],
cat_displays: vec!["Zone".into()],
entries: grid.row_frame.entries.clone(),
}));
assert!(grid.totals.iter().all(|t| t.text.is_empty()));
@@ -1343,11 +1427,7 @@ mod tests {
#[test]
fn the_page_bar_outlives_a_reshape() {
let mut grid = framed_grid();
let payer = PageAxis {
cat: "Payer".into(),
selected: "Alice".into(),
items: Some(vec!["Alice".into(), "Bob".into()]),
};
let payer = PageAxis::plain("Payer", "Alice", Some(vec!["Alice".into(), "Bob".into()]));
grid.apply(&GridUpdate::PageCoords(vec![payer.clone()]));
grid.apply(&GridUpdate::RowFrame(AxisFrame::default()));
grid.apply(&GridUpdate::ColFrame(AxisFrame::default()));
@@ -1361,6 +1441,7 @@ mod tests {
AxisFrame {
first: 0,
cats: vec!["Type".into(), "Who".into()],
cat_displays: vec!["Type".into(), "Who".into()],
entries: vec![
FrameEntry::item(vec!["A".into(), "x".into()]),
FrameEntry::item(vec!["A".into(), "y".into()]),
@@ -1368,6 +1449,7 @@ mod tests {
level: 0,
depth: 0,
name: "Late".into(),
display: "Late".into(),
collapsed: true,
}),
FrameEntry::item(vec!["B".into(), "z".into()]),
@@ -1375,6 +1457,72 @@ mod tests {
}
}
/// Two keys per level shown under displays: the outer pair share a display,
/// the inner pair share a KEY. Suppression must follow the keys.
fn shown_frame() -> AxisFrame {
let item = |tuple: [&str; 2], display: [&str; 2]| FrameEntry::Item {
tuple: tuple.map(String::from).to_vec(),
display: display.map(String::from).to_vec(),
collapsed: false,
};
AxisFrame {
cats: vec!["Region".into(), "Month".into()],
cat_displays: vec!["Sales Area".into(), "Period".into()],
first: 0,
entries: vec![
item(["East", "Jan"], ["Same", "January"]),
item(["East", "Feb"], ["Same", "February"]),
item(["West", "Jan"], ["Same", "January"]),
],
}
}
#[test]
fn a_header_draws_displays_and_blanks_a_repeat_by_key() {
let frame = shown_frame();
assert_eq!(
frame.header_labels(0),
vec![
vec!["Same".to_string(), "January".into()],
vec![String::new(), "February".into()],
vec!["Same".to_string(), "January".into()],
],
"East repeats and blanks; West shares East's DISPLAY and still draws"
);
let keys: Vec<&Vec<String>> = frame.items().collect();
assert_eq!(keys[2], &vec!["West".to_string(), "Jan".into()]);
assert_eq!(frame.label(0, 0), "East", "the lookup is still the key");
}
#[test]
fn a_header_is_as_wide_as_what_it_shows() {
let frame = AxisFrame {
cats: vec!["R".into()],
cat_displays: vec!["Region".into()],
first: 0,
entries: vec![FrameEntry::Item {
tuple: vec!["E".into()],
display: vec!["Eastern Seaboard".into()],
collapsed: false,
}],
};
assert_eq!(frame.header_widths(), vec!["Eastern Seaboard".len() as u16 + 1]);
}
#[test]
fn a_frame_with_ragged_displays_is_refused() {
let good = serde_json::to_value(shown_frame()).expect("a frame serializes");
assert!(serde_json::from_value::<AxisFrame>(good.clone()).is_ok());
let mut short_cats = good.clone();
short_cats["cat_displays"] = serde_json::json!(["Only one"]);
let err = serde_json::from_value::<AxisFrame>(short_cats).unwrap_err();
assert!(err.to_string().contains("category displays"), "{err}");
let mut short_tuple = good;
short_tuple["entries"][0]["Item"]["display"] = serde_json::json!(["Same"]);
let err = serde_json::from_value::<AxisFrame>(short_tuple).unwrap_err();
assert!(err.to_string().contains("displays for a tuple"), "{err}");
}
/// Two runs of one group name under two different outer items, with nothing
/// ungrouped between them — improvise-h0iv's reachable shape.
fn repeated_group_frame() -> AxisFrame {
@@ -1382,11 +1530,13 @@ mod tests {
level: 1,
depth: 0,
name: "Q1".into(),
display: "Q1".into(),
collapsed: false,
};
AxisFrame {
first: 0,
cats: vec!["Region".into(), "Month".into()],
cat_displays: vec!["Region".into(), "Month".into()],
entries: vec![
FrameEntry::Group(q1()),
FrameEntry::item(vec!["North".into(), "Jul".into()]),
@@ -1404,11 +1554,13 @@ mod tests {
AxisFrame {
first: 0,
cats: vec!["Month".into()],
cat_displays: vec!["Month".into()],
entries: vec![
FrameEntry::Group(FrameGroup {
level: 0,
depth: 0,
name: "Q1".into(),
display: "Q1".into(),
collapsed: false,
}),
FrameEntry::item(vec!["Jan".into()]),
@@ -1428,11 +1580,13 @@ mod tests {
level,
depth: 0,
name: name.into(),
display: name.into(),
collapsed: true,
})
};
let frame = AxisFrame {
cats: vec!["Region".into(), "Month".into()],
cat_displays: vec!["Region".into(), "Month".into()],
first: 0,
entries: vec![
group(0, "Upper"),
@@ -1473,11 +1627,13 @@ mod tests {
level: 0,
depth,
name: name.into(),
display: name.into(),
collapsed: false,
})
};
let frame = AxisFrame {
cats: vec!["Date".into()],
cat_displays: vec!["Date".into()],
first: 0,
entries: vec![
group(0, "2024"),
@@ -1527,11 +1683,13 @@ mod tests {
let frame = AxisFrame {
first: 0,
cats: vec!["Month".into()],
cat_displays: vec!["Month".into()],
entries: vec![
FrameEntry::Group(FrameGroup {
level: 0,
depth: 0,
name: "Q1".into(),
display: "Q1".into(),
collapsed: true,
}),
FrameEntry::GroupEnd { level: 0, depth: 0 },
@@ -1698,6 +1856,7 @@ mod tests {
let frame = AxisFrame {
first: 0,
cats: vec!["A".into(), "B".into()],
cat_displays: vec!["A".into(), "B".into()],
entries: vec![FrameEntry::item(vec!["Clothing".into(), "日本".into()])],
};
assert_eq!(
@@ -1736,6 +1895,7 @@ mod tests {
let frame = AxisFrame {
first: 0,
cats: Vec::new(),
cat_displays: Vec::new(),
entries: vec![FrameEntry::item(Vec::new())],
};
assert_eq!(frame.levels(), 1);
@@ -1756,7 +1916,10 @@ mod tests {
},
]);
assert_eq!(tree.category("Region").unwrap().items.len(), 2);
assert_eq!(tree.category("Product").unwrap().items, vec!["Shirts"]);
assert_eq!(
tree.category("Product").unwrap().items,
vec![CategoryItem::plain("Shirts")]
);
// An update for a category the cache has never heard of is dropped.
tree.apply(&CategoryTreeUpdate::Items {
@@ -1784,7 +1947,7 @@ mod tests {
assert_eq!(tree.category("Product").unwrap().axis, Axis::Row);
assert_eq!(
tree.category("Region").unwrap().items,
vec!["North"],
vec![CategoryItem::plain("North")],
"moving an axis does not disturb membership"
);
}
@@ -1813,7 +1976,7 @@ mod tests {
let views = ViewListCache::folded(&[vec![ViewSummary {
name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::Row)],
axes: vec![ViewAxis::plain("Region", Axis::Row)],
}]]);
assert_eq!(views.views[0].name, "Default");
@@ -1869,6 +2032,7 @@ mod tests {
let axis = |cat: &str, labels: [&str; 2]| AxisFrame {
first: 0,
cats: vec![cat.into()],
cat_displays: vec![cat.into()],
entries: labels
.iter()
.map(|l| FrameEntry::item(vec![(*l).into()]))
@@ -1895,6 +2059,7 @@ mod tests {
let renamed = AxisFrame {
first: 0,
cats: vec!["Department".into()],
cat_displays: vec!["Department".into()],
entries: grid.row_frame.entries.clone(),
};
assert_eq!(
@@ -1935,6 +2100,7 @@ mod tests {
let renamed = AxisFrame {
first: 0,
cats: vec!["Metric".into()],
cat_displays: vec!["Metric".into()],
entries: grid.col_frame.entries.clone(),
};
grid.apply(&GridUpdate::ColFrame(renamed));
@@ -2215,7 +2381,7 @@ mod tests {
Projection::ViewList(vec![ViewSummary {
name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::None)],
axes: vec![ViewAxis::plain("Region", Axis::None)],
}]),
Projection::TileBar(vec![TileEntry {
category: "Region".into(),
+10 -25
View File
@@ -1613,6 +1613,7 @@ fn filter_to_item_at(state: &ClientState, index: usize) -> Option<Vec<Command>>
Some(CatTreeEntry::Item {
cat_name,
item_name,
..
}) => Some(vec![
model(
state,
@@ -1648,6 +1649,7 @@ fn delete_at_cat_cursor(state: &ClientState) -> Option<Vec<Command>> {
Some(CatTreeEntry::Item {
cat_name,
item_name,
..
}) => {
let mut commands = vec![
model(
@@ -2204,6 +2206,7 @@ mod tests {
Some(CatTreeEntry::Item {
cat_name: "Product".into(),
item_name: "Shirts".into(),
display: "Shirts".into(),
}),
"row 4 is Product's ONLY item — no sibling above it and none below"
);
@@ -2628,10 +2631,12 @@ mod tests {
// Named axes, as a host's always are: `H` hides from `cats[0]`.
Projection::Grid(GridUpdate::RowFrame(AxisFrame {
cats: vec!["Region".into()],
cat_displays: vec!["Region".into()],
..AxisFrame::flat(["North", "South", "East"])
})),
Projection::Grid(GridUpdate::ColFrame(AxisFrame {
cats: vec!["_Measure".into()],
cat_displays: vec!["_Measure".into()],
..AxisFrame::flat(["Revenue", "Cost", "Profit"])
})),
Projection::Grid(GridUpdate::Cells(cells)),
@@ -2665,11 +2670,7 @@ mod tests {
// produce, so the fixture projects both (improvise-e5pf).
crate::WidgetCache::apply(&mut state.grid, &GridUpdate::Records(mode.is_records()));
state.fold(&Command::ChangeMode(mode));
state.grid.page_coords = vec![crate::PageAxis {
cat: "Quarter".into(),
selected: "Q2".into(),
items: Some(vec!["Q1".into(), "Q2".into(), "Q3".into()]),
}];
state.grid.page_coords = vec![crate::PageAxis::plain("Quarter", "Q2", Some(vec!["Q1".into(), "Q2".into(), "Q3".into()]))];
// THREE of everything, every panel cursor seated in the MIDDLE: an edge
// makes `move-panel-cursor` answer `Ignored` for the wrong reason.
crate::WidgetCache::apply(
@@ -3989,16 +3990,8 @@ mod tests {
let mut state = in_mode(AppMode::Normal);
crate::WidgetCache::apply(&mut state.basis, &published());
state.grid.page_coords = vec![
crate::PageAxis {
cat: "Year".into(),
selected: "2024".into(),
items: Some(vec!["2024".into(), "2025".into()]),
},
crate::PageAxis {
cat: "Quarter".into(),
selected: "Q4".into(),
items: Some(vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()]),
},
crate::PageAxis::plain("Year", "2024", Some(vec!["2024".into(), "2025".into()])),
crate::PageAxis::plain("Quarter", "Q4", Some(vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()])),
];
state
}
@@ -4047,11 +4040,7 @@ mod tests {
#[test]
fn a_filter_axis_is_projected_but_not_cycled() {
let mut state = paged();
state.grid.page_coords.push(crate::PageAxis {
cat: "Region".into(),
selected: "East".into(),
items: None,
});
state.grid.page_coords.push(crate::PageAxis::plain("Region", "East", None));
let moved = selections(resolve(&state, ev(KeyCode::Char(']'))));
assert!(
!moved.iter().any(|(cat, _)| cat == "Region"),
@@ -4087,11 +4076,7 @@ mod tests {
#[test]
fn a_filter_axis_offers_no_item_to_pick() {
let mut state = paged();
state.grid.page_coords.push(crate::PageAxis {
cat: "Region".into(),
selected: "East".into(),
items: None,
});
state.grid.page_coords.push(crate::PageAxis::plain("Region", "East", None));
assert!(
click(&state, Click::PageItem { axis: 2, item: 0 }).is_empty(),
"a Filter axis has no item list, so there is nothing a click could mean"
+13 -7
View File
@@ -19,8 +19,12 @@ pub enum CatTreeEntry {
expanded: bool,
axis: Axis,
},
/// Item row under a category.
Item { cat_name: String, item_name: String },
/// Item row under a category: the keys a command names, and what is drawn.
Item {
cat_name: String,
item_name: String,
display: String,
},
}
impl CatTreeEntry {
@@ -56,10 +60,11 @@ pub fn flatten_cat_tree(
axis: row.axis,
});
if is_expanded {
for item_name in &row.items {
for item in &row.items {
entries.push(CatTreeEntry::Item {
cat_name: row.name.clone(),
item_name: item_name.clone(),
item_name: item.name.clone(),
display: item.display.clone(),
});
}
}
@@ -76,8 +81,8 @@ pub fn view_axis_summary(summary: &ViewSummary) -> String {
let cats: Vec<&str> = summary
.axes
.iter()
.filter(|(cat, a)| *a == axis && !cat.starts_with('_'))
.map(|(cat, _)| cat.as_str())
.filter(|a| a.axis == axis && !a.category.starts_with('_'))
.map(|a| a.display.as_str())
.collect();
(!cats.is_empty()).then(|| format!("{}:{}", axis.initial(), cats.join(",")))
})
@@ -97,7 +102,7 @@ mod tests {
.map(|(name, items)| CategoryRow {
name: (*name).to_string(),
display: (*name).to_string(),
items: items.iter().map(|i| i.to_string()).collect(),
items: items.iter().map(|i| (*i).into()).collect(),
axis: Axis::None,
})
.collect(),
@@ -136,6 +141,7 @@ mod tests {
CatTreeEntry::Item {
cat_name: "Region".into(),
item_name: "South".into(),
display: "South".into(),
}
);
}
+10 -6
View File
@@ -654,6 +654,7 @@ mod tests {
let frame = AxisFrame {
first: 0,
cats: vec!["Month".into()],
cat_displays: vec!["Month".into()],
entries: vec![
group("Q1", false),
item("Jan"),
@@ -887,7 +888,10 @@ mod tests {
ViewSummary {
name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::Row), ("_Dim".into(), Axis::Column)],
axes: vec![
improvise_protocol::ViewAxis::plain("Region", Axis::Row),
improvise_protocol::ViewAxis::plain("_Dim", Axis::Column),
],
},
ViewSummary {
name: "Plan".into(),
@@ -1135,11 +1139,11 @@ mod tests {
}
fn page_axis(cat: &str, selected: &str, items: Option<Vec<&str>>) -> PageAxis {
PageAxis {
cat: cat.into(),
selected: selected.into(),
items: items.map(|i| i.into_iter().map(String::from).collect()),
}
PageAxis::plain(
cat,
selected,
items.map(|i| i.into_iter().map(String::from).collect()),
)
}
/// improvise-p7lh: the page bar reached the client's cache and stopped