feat: group-aware grid rendering and hide/show item
Builds out two half-finished view features: Group collapse: - AxisEntry enum distinguishes GroupHeader from DataItem on grid axes - expand_category() emits group headers and filters collapsed items - Grid renders inline group header rows with ▼/▶ indicator - `z` keybinding toggles collapse of nearest group above cursor Hide/show item: - Restore show_item() (was commented out alongside hide_item) - Add HideItem / ShowItem commands and dispatch - `H` keybinding hides the current row item - `:show-item <cat> <item>` command to restore hidden items - Restore silenced test assertions for hide/show round-trip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -1,62 +1,156 @@
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::CellKey;
|
||||
use crate::model::Model;
|
||||
use crate::view::{Axis, View};
|
||||
|
||||
/// One entry on a grid axis: either a visual group header or a data-item tuple.
|
||||
///
|
||||
/// `GroupHeader` entries are always visible so the user can see the group label
|
||||
/// and toggle its collapse state. `DataItem` entries are absent when their
|
||||
/// group is collapsed.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AxisEntry {
|
||||
GroupHeader {
|
||||
cat_name: String,
|
||||
group_name: String,
|
||||
},
|
||||
DataItem(Vec<String>),
|
||||
}
|
||||
|
||||
/// The resolved 2-D layout of a view: which item tuples appear on each axis,
|
||||
/// what page filter is active, and how to map (row, col) → CellKey.
|
||||
///
|
||||
/// This is the single authoritative place that converts the multi-dimensional
|
||||
/// model into the flat grid consumed by both the terminal renderer and CSV exporter.
|
||||
pub struct GridLayout {
|
||||
pub row_cats: Vec<String>,
|
||||
pub col_cats: Vec<String>,
|
||||
pub row_cats: Vec<String>,
|
||||
pub col_cats: Vec<String>,
|
||||
pub page_coords: Vec<(String, String)>,
|
||||
pub row_items: Vec<Vec<String>>,
|
||||
pub col_items: Vec<Vec<String>>,
|
||||
pub row_items: Vec<AxisEntry>,
|
||||
pub col_items: Vec<AxisEntry>,
|
||||
}
|
||||
|
||||
impl GridLayout {
|
||||
pub fn new(model: &Model, view: &View) -> Self {
|
||||
let row_cats: Vec<String> = view.categories_on(Axis::Row)
|
||||
.into_iter().map(String::from).collect();
|
||||
let col_cats: Vec<String> = view.categories_on(Axis::Column)
|
||||
.into_iter().map(String::from).collect();
|
||||
let page_cats: Vec<String> = view.categories_on(Axis::Page)
|
||||
.into_iter().map(String::from).collect();
|
||||
let row_cats: Vec<String> = view
|
||||
.categories_on(Axis::Row)
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let col_cats: Vec<String> = view
|
||||
.categories_on(Axis::Column)
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let page_cats: Vec<String> = view
|
||||
.categories_on(Axis::Page)
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
let page_coords = page_cats.iter().map(|cat| {
|
||||
let items: Vec<String> = model.category(cat)
|
||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
let sel = view.page_selection(cat)
|
||||
.map(String::from)
|
||||
.or_else(|| items.first().cloned())
|
||||
.unwrap_or_default();
|
||||
(cat.clone(), sel)
|
||||
}).collect();
|
||||
let page_coords = page_cats
|
||||
.iter()
|
||||
.map(|cat| {
|
||||
let items: Vec<String> = model
|
||||
.category(cat)
|
||||
.map(|c| {
|
||||
c.ordered_item_names()
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let sel = view
|
||||
.page_selection(cat)
|
||||
.map(String::from)
|
||||
.or_else(|| items.first().cloned())
|
||||
.unwrap_or_default();
|
||||
(cat.clone(), sel)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let row_items = cross_product(model, view, &row_cats);
|
||||
let col_items = cross_product(model, view, &col_cats);
|
||||
|
||||
Self { row_cats, col_cats, page_coords, row_items, col_items }
|
||||
Self {
|
||||
row_cats,
|
||||
col_cats,
|
||||
page_coords,
|
||||
row_items,
|
||||
col_items,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn row_count(&self) -> usize { self.row_items.len() }
|
||||
pub fn col_count(&self) -> usize { self.col_items.len() }
|
||||
/// Number of data rows (group headers excluded).
|
||||
pub fn row_count(&self) -> usize {
|
||||
self.row_items
|
||||
.iter()
|
||||
.filter(|e| matches!(e, AxisEntry::DataItem(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Number of data columns (group headers excluded).
|
||||
pub fn col_count(&self) -> usize {
|
||||
self.col_items
|
||||
.iter()
|
||||
.filter(|e| matches!(e, AxisEntry::DataItem(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn row_label(&self, row: usize) -> String {
|
||||
self.row_items.get(row).map(|r| r.join("/")).unwrap_or_default()
|
||||
self.row_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
Some(v)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.nth(row)
|
||||
.map(|r| r.join("/"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn col_label(&self, col: usize) -> String {
|
||||
self.col_items.get(col).map(|c| c.join("/")).unwrap_or_default()
|
||||
self.col_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
Some(v)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.nth(col)
|
||||
.map(|c| c.join("/"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Build the CellKey for the data cell at (row, col), including the active
|
||||
/// page-axis filter. Returns None if row or col is out of bounds.
|
||||
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
|
||||
let row_item = self.row_items.get(row)?;
|
||||
let col_item = self.col_items.get(col)?;
|
||||
let row_item = self
|
||||
.row_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
Some(v)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.nth(row)?;
|
||||
let col_item = self
|
||||
.col_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
Some(v)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.nth(col)?;
|
||||
let mut coords = self.page_coords.clone();
|
||||
for (cat, item) in self.row_cats.iter().zip(row_item.iter()) {
|
||||
coords.push((cat.clone(), item.clone()));
|
||||
@ -66,48 +160,126 @@ impl GridLayout {
|
||||
}
|
||||
Some(CellKey::new(coords))
|
||||
}
|
||||
|
||||
/// Visual index of the nth data row (skipping group headers).
|
||||
pub fn data_row_to_visual(&self, data_row: usize) -> Option<usize> {
|
||||
let mut count = 0;
|
||||
for (vi, entry) in self.row_items.iter().enumerate() {
|
||||
if let AxisEntry::DataItem(_) = entry {
|
||||
if count == data_row {
|
||||
return Some(vi);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Visual index of the nth data column (skipping group headers).
|
||||
pub fn data_col_to_visual(&self, data_col: usize) -> Option<usize> {
|
||||
let mut count = 0;
|
||||
for (vi, entry) in self.col_items.iter().enumerate() {
|
||||
if let AxisEntry::DataItem(_) = entry {
|
||||
if count == data_col {
|
||||
return Some(vi);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand a single category into `AxisEntry` values, given a coordinate prefix.
|
||||
/// Emits a `GroupHeader` at each group boundary, then `DataItem` entries for
|
||||
/// visible, non-collapsed items.
|
||||
fn expand_category(
|
||||
model: &Model,
|
||||
view: &View,
|
||||
cat_name: &str,
|
||||
prefix: Vec<String>,
|
||||
) -> Vec<AxisEntry> {
|
||||
let Some(cat) = model.category(cat_name) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut result = Vec::new();
|
||||
let mut last_group: Option<&str> = None;
|
||||
|
||||
for item_name in cat.ordered_item_names() {
|
||||
if view.is_hidden(cat_name, item_name) {
|
||||
continue;
|
||||
}
|
||||
let item_group = cat.items.get(item_name).and_then(|i| i.group.as_deref());
|
||||
|
||||
// Emit a group header at each group boundary.
|
||||
if item_group != last_group {
|
||||
if let Some(g) = item_group {
|
||||
result.push(AxisEntry::GroupHeader {
|
||||
cat_name: cat_name.to_string(),
|
||||
group_name: g.to_string(),
|
||||
});
|
||||
}
|
||||
last_group = item_group;
|
||||
}
|
||||
|
||||
// Skip the data item if its group is collapsed.
|
||||
if item_group.map_or(false, |g| view.is_group_collapsed(cat_name, g)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut row = prefix.clone();
|
||||
row.push(item_name.to_string());
|
||||
result.push(AxisEntry::DataItem(row));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Cartesian product of visible items across `cats`, in category order.
|
||||
/// Hidden items are excluded. Returns `vec![vec![]]` when `cats` is empty.
|
||||
fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<Vec<String>> {
|
||||
/// Hidden items and items in collapsed groups are excluded from `DataItem`
|
||||
/// entries; group headers are always emitted.
|
||||
/// Returns `vec![DataItem(vec![])]` when `cats` is empty.
|
||||
fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry> {
|
||||
if cats.is_empty() {
|
||||
return vec![vec![]];
|
||||
return vec![AxisEntry::DataItem(vec![])];
|
||||
}
|
||||
let mut result: Vec<Vec<String>> = vec![vec![]];
|
||||
let mut result: Vec<AxisEntry> = vec![AxisEntry::DataItem(vec![])];
|
||||
for cat_name in cats {
|
||||
let items: Vec<String> = model.category(cat_name)
|
||||
.map(|c| c.ordered_item_names().into_iter()
|
||||
.filter(|item| !view.is_hidden(cat_name, item))
|
||||
.map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
result = result.into_iter().flat_map(|prefix| {
|
||||
items.iter().map(move |item| {
|
||||
let mut row = prefix.clone();
|
||||
row.push(item.clone());
|
||||
row
|
||||
result = result
|
||||
.into_iter()
|
||||
.flat_map(|entry| match entry {
|
||||
AxisEntry::DataItem(prefix) => expand_category(model, view, cat_name, prefix),
|
||||
header @ AxisEntry::GroupHeader { .. } => vec![header],
|
||||
})
|
||||
}).collect();
|
||||
.collect();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::GridLayout;
|
||||
use crate::model::Model;
|
||||
use super::{AxisEntry, GridLayout};
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
|
||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||
CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect())
|
||||
CellKey::new(
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(c, i)| (c.to_string(), i.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn two_cat_model() -> Model {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Type").unwrap();
|
||||
m.add_category("Month").unwrap();
|
||||
for item in ["Food", "Clothing"] { m.category_mut("Type").unwrap().add_item(item); }
|
||||
for item in ["Jan", "Feb"] { m.category_mut("Month").unwrap().add_item(item); }
|
||||
for item in ["Food", "Clothing"] {
|
||||
m.category_mut("Type").unwrap().add_item(item);
|
||||
}
|
||||
for item in ["Jan", "Feb"] {
|
||||
m.category_mut("Month").unwrap().add_item(item);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
@ -174,8 +346,141 @@ mod tests {
|
||||
m.category_mut("Type").unwrap().add_item("Food");
|
||||
m.category_mut("Month").unwrap().add_item("Jan");
|
||||
m.category_mut("Year").unwrap().add_item("2025");
|
||||
m.active_view_mut().set_axis("Year", crate::view::Axis::Column);
|
||||
m.active_view_mut()
|
||||
.set_axis("Year", crate::view::Axis::Column);
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
assert_eq!(layout.col_label(0), "Jan/2025");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_count_excludes_group_headers() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Month").unwrap();
|
||||
m.add_category("Type").unwrap();
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Jan", "Q1");
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Feb", "Q1");
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Apr", "Q2");
|
||||
m.category_mut("Type").unwrap().add_item("Food");
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_header_emitted_at_group_boundary() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Month").unwrap();
|
||||
m.add_category("Type").unwrap();
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Jan", "Q1");
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Apr", "Q2");
|
||||
m.category_mut("Type").unwrap().add_item("Food");
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
let headers: Vec<_> = layout
|
||||
.row_items
|
||||
.iter()
|
||||
.filter(|e| matches!(e, AxisEntry::GroupHeader { .. }))
|
||||
.collect();
|
||||
assert_eq!(headers.len(), 2);
|
||||
assert!(
|
||||
matches!(&headers[0], AxisEntry::GroupHeader { group_name, .. } if group_name == "Q1")
|
||||
);
|
||||
assert!(
|
||||
matches!(&headers[1], AxisEntry::GroupHeader { group_name, .. } if group_name == "Q2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_group_has_header_but_no_data_items() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Month").unwrap();
|
||||
m.add_category("Type").unwrap();
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Jan", "Q1");
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Feb", "Q1");
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Apr", "Q2");
|
||||
m.category_mut("Type").unwrap().add_item("Food");
|
||||
m.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
// Q1 collapsed: header present, Jan and Feb absent; Q2 intact
|
||||
assert_eq!(layout.row_count(), 1); // only Apr
|
||||
let q1_header = layout
|
||||
.row_items
|
||||
.iter()
|
||||
.find(|e| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "Q1"));
|
||||
assert!(q1_header.is_some());
|
||||
let jan = layout
|
||||
.row_items
|
||||
.iter()
|
||||
.find(|e| matches!(e, AxisEntry::DataItem(v) if v.contains(&"Jan".to_string())));
|
||||
assert!(jan.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungrouped_items_produce_no_headers() {
|
||||
let m = two_cat_model();
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
assert!(!layout
|
||||
.row_items
|
||||
.iter()
|
||||
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
|
||||
assert!(!layout
|
||||
.col_items
|
||||
.iter()
|
||||
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_key_correct_with_grouped_items() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Month").unwrap();
|
||||
m.add_category("Type").unwrap();
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Jan", "Q1");
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Apr", "Q2");
|
||||
m.category_mut("Type").unwrap().add_item("Food");
|
||||
m.set_cell(
|
||||
coord(&[("Month", "Apr"), ("Type", "Food")]),
|
||||
CellValue::Number(99.0),
|
||||
);
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
// data row 0 = Jan, data row 1 = Apr
|
||||
let key = layout.cell_key(1, 0).unwrap();
|
||||
assert_eq!(m.evaluate(&key), Some(CellValue::Number(99.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_row_to_visual_skips_headers() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Month").unwrap();
|
||||
m.add_category("Type").unwrap();
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Jan", "Q1");
|
||||
m.category_mut("Month")
|
||||
.unwrap()
|
||||
.add_item_in_group("Apr", "Q2");
|
||||
m.category_mut("Type").unwrap().add_item("Food");
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
// visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
|
||||
assert_eq!(layout.data_row_to_visual(0), Some(1)); // Jan is at visual index 1
|
||||
assert_eq!(layout.data_row_to_visual(1), Some(3)); // Apr is at visual index 3
|
||||
assert_eq!(layout.data_row_to_visual(2), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user