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:
committed by
Edward Langley
parent
bde258adec
commit
386b693bf5
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user