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), } /// 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, pub col_cats: Vec, pub page_coords: Vec<(String, String)>, pub row_items: Vec, pub col_items: Vec, } impl GridLayout { pub fn new(model: &Model, view: &View) -> Self { let row_cats: Vec = view .categories_on(Axis::Row) .into_iter() .map(String::from) .collect(); let col_cats: Vec = view .categories_on(Axis::Column) .into_iter() .map(String::from) .collect(); let page_cats: Vec = view .categories_on(Axis::Page) .into_iter() .map(String::from) .collect(); let page_coords = page_cats .iter() .map(|cat| { let items: Vec = 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, } } /// 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 .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 .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 { 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())); } for (cat, item) in self.col_cats.iter().zip(col_item.iter()) { coords.push((cat.clone(), item.clone())); } 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 { 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 { 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, ) -> Vec { 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.is_some_and(|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 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 { if cats.is_empty() { return vec![AxisEntry::DataItem(vec![])]; } let mut result: Vec = vec![AxisEntry::DataItem(vec![])]; for cat_name in cats { 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(); } result } #[cfg(test)] mod tests { 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(), ) } 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); } m } #[test] fn row_and_col_counts_match_item_counts() { let m = two_cat_model(); let layout = GridLayout::new(&m, m.active_view()); assert_eq!(layout.row_count(), 2); // Food, Clothing assert_eq!(layout.col_count(), 2); // Jan, Feb } #[test] fn cell_key_encodes_correct_coordinates() { let m = two_cat_model(); let layout = GridLayout::new(&m, m.active_view()); // row 0 = Food, col 1 = Feb let key = layout.cell_key(0, 1).unwrap(); assert_eq!(key, coord(&[("Month", "Feb"), ("Type", "Food")])); } #[test] fn cell_key_out_of_bounds_returns_none() { let m = two_cat_model(); let layout = GridLayout::new(&m, m.active_view()); assert!(layout.cell_key(99, 0).is_none()); assert!(layout.cell_key(0, 99).is_none()); } #[test] fn cell_key_includes_page_coords() { let mut m = Model::new("T"); m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); m.add_category("Region").unwrap(); m.category_mut("Type").unwrap().add_item("Food"); m.category_mut("Month").unwrap().add_item("Jan"); m.category_mut("Region").unwrap().add_item("East"); m.category_mut("Region").unwrap().add_item("West"); m.active_view_mut().set_page_selection("Region", "West"); let layout = GridLayout::new(&m, m.active_view()); let key = layout.cell_key(0, 0).unwrap(); assert_eq!(key.get("Region"), Some("West")); } #[test] fn cell_key_round_trips_through_model_evaluate() { let mut m = two_cat_model(); m.set_cell( coord(&[("Month", "Feb"), ("Type", "Clothing")]), CellValue::Number(42.0), ); let layout = GridLayout::new(&m, m.active_view()); // Clothing = row 1, Feb = col 1 let key = layout.cell_key(1, 1).unwrap(); assert_eq!(m.evaluate(&key), Some(CellValue::Number(42.0))); } #[test] fn labels_join_with_slash_for_multi_cat_axis() { let mut m = Model::new("T"); m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); m.add_category("Year").unwrap(); 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); 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); } }