diff --git a/src/command/cmd.rs b/src/command/cmd.rs index 7c99998..b7750ac 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -672,7 +672,16 @@ impl Cmd for EditOrDrill { "edit-or-drill" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let is_aggregated = ctx.records_col.is_none() && !ctx.none_cats.is_empty(); + // Only consider regular (non-virtual, non-label) categories on None + // as true aggregation. Virtuals like _Index/_Dim are always None in + // pivot mode and don't imply aggregation. + let regular_none = ctx.none_cats.iter().any(|c| { + ctx.model + .category(c) + .map(|cat| cat.kind.is_regular()) + .unwrap_or(false) + }); + let is_aggregated = ctx.records_col.is_none() && regular_none; if is_aggregated { let Some(key) = ctx.cell_key.clone() else { return vec![effect::set_status( diff --git a/src/view/layout.rs b/src/view/layout.rs index ef87a75..7cf4066 100644 --- a/src/view/layout.rs +++ b/src/view/layout.rs @@ -53,6 +53,9 @@ impl GridLayout { layout.records = Some(records); } } + if view.prune_empty { + layout.prune_empty(model); + } layout } @@ -152,10 +155,11 @@ impl GridLayout { .map(|i| AxisEntry::DataItem(vec![i.to_string()])) .collect(); - // Synthesize col items: one per category + "Value" + // Synthesize col items: one per non-virtual category + "Value" let cat_names: Vec = model .category_names() .into_iter() + .filter(|c| !c.starts_with('_')) .map(String::from) .collect(); let mut col_items: Vec = cat_names @@ -195,6 +199,108 @@ impl GridLayout { } } + /// Remove data rows where every column is empty and data columns + /// where every row is empty. Group headers are kept if at least one + /// of their data items survives. + /// + /// In records mode every column is shown (the user drilled in to see + /// all the raw data). In pivot mode, rows and columns where every + /// cell is empty are hidden to reduce clutter. + pub fn prune_empty(&mut self, model: &Model) { + if self.is_records_mode() { + return; + } + let rc = self.row_count(); + let cc = self.col_count(); + if rc == 0 || cc == 0 { + return; + } + + // Build a row×col grid of "has content?" + let mut has_value = vec![vec![false; cc]; rc]; + for ri in 0..rc { + for ci in 0..cc { + has_value[ri][ci] = if self.is_records_mode() { + let s = self.records_display(ri, ci).unwrap_or_default(); + !s.is_empty() + } else { + self.cell_key(ri, ci) + .and_then(|k| model.evaluate_aggregated(&k, &self.none_cats)) + .is_some() + }; + } + } + + // Which data-row indices are non-empty? + let keep_row: Vec = (0..rc) + .map(|ri| (0..cc).any(|ci| has_value[ri][ci])) + .collect(); + // Which data-col indices are non-empty? + let keep_col: Vec = (0..cc) + .map(|ci| (0..rc).any(|ri| has_value[ri][ci])) + .collect(); + + // Filter row_items, preserving group headers when at least one + // subsequent data item survives. + let mut new_rows = Vec::new(); + let mut pending_header: Option = None; + let mut data_idx = 0usize; + for entry in self.row_items.drain(..) { + match &entry { + AxisEntry::GroupHeader { .. } => { + pending_header = Some(entry); + } + AxisEntry::DataItem(_) => { + if data_idx < rc && keep_row[data_idx] { + if let Some(h) = pending_header.take() { + new_rows.push(h); + } + new_rows.push(entry); + } + data_idx += 1; + } + } + } + self.row_items = new_rows; + + // Filter col_items (same logic) + let mut new_cols = Vec::new(); + let mut pending_header: Option = None; + let mut data_idx = 0usize; + for entry in self.col_items.drain(..) { + match &entry { + AxisEntry::GroupHeader { .. } => { + pending_header = Some(entry); + } + AxisEntry::DataItem(_) => { + if data_idx < cc && keep_col[data_idx] { + if let Some(h) = pending_header.take() { + new_cols.push(h); + } + new_cols.push(entry); + } + data_idx += 1; + } + } + } + self.col_items = new_cols; + + // If records mode, also prune the records vec and re-index row_items + if let Some(records) = &self.records { + let new_records: Vec<_> = keep_row + .iter() + .enumerate() + .filter(|(_, keep)| **keep) + .map(|(i, _)| records[i].clone()) + .collect(); + let new_row_items: Vec = (0..new_records.len()) + .map(|i| AxisEntry::DataItem(vec![i.to_string()])) + .collect(); + self.row_items = new_row_items; + self.records = Some(new_records); + } + } + /// Whether this layout is in records mode. pub fn is_records_mode(&self) -> bool { self.records.is_some() @@ -450,6 +556,30 @@ mod tests { m } + #[test] + fn prune_empty_removes_all_empty_columns_in_pivot_mode() { + let mut m = Model::new("T"); + m.add_category("Row").unwrap(); + m.add_category("Col").unwrap(); + m.category_mut("Row").unwrap().add_item("A"); + m.category_mut("Col").unwrap().add_item("X"); + m.category_mut("Col").unwrap().add_item("Y"); + // Only X has data; Y is entirely empty + m.set_cell( + CellKey::new(vec![ + ("Row".into(), "A".into()), + ("Col".into(), "X".into()), + ]), + CellValue::Number(1.0), + ); + + let mut layout = GridLayout::new(&m, m.active_view()); + assert_eq!(layout.col_count(), 2); // X and Y before pruning + layout.prune_empty(&m); + assert_eq!(layout.col_count(), 1); // only X after pruning + assert_eq!(layout.col_label(0), "X"); + } + #[test] fn records_mode_activated_when_index_and_dim_on_axes() { let mut m = records_model(); diff --git a/src/view/types.rs b/src/view/types.rs index 22eec22..f8315d1 100644 --- a/src/view/types.rs +++ b/src/view/types.rs @@ -4,6 +4,10 @@ use std::collections::{HashMap, HashSet}; use super::axis::Axis; +fn default_prune() -> bool { + true +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct View { pub name: String, @@ -17,6 +21,9 @@ pub struct View { pub collapsed_groups: HashMap>, /// Number format string (e.g. ",.0f" for comma-separated integer) pub number_format: String, + /// When true, empty rows/columns are pruned from the display. + #[serde(default = "default_prune")] + pub prune_empty: bool, /// Scroll offset for grid pub row_offset: usize, pub col_offset: usize, @@ -33,6 +40,7 @@ impl View { hidden_items: HashMap::new(), collapsed_groups: HashMap::new(), number_format: ",.0".to_string(), + prune_empty: false, row_offset: 0, col_offset: 0, selected: (0, 0), @@ -41,16 +49,47 @@ impl View { pub fn on_category_added(&mut self, cat_name: &str) { if !self.category_axes.contains_key(cat_name) { - // Virtual categories (names starting with `_`) default to Axis::None. + // Virtual/underscore categories default to Axis::None. // Regular categories auto-assign: first → Row, second → Column, rest → Page. + // If a virtual currently holds Row or Column and a regular category needs + // the slot, bump the virtual to None. let axis = if cat_name.starts_with('_') { Axis::None } else { - let rows = self.categories_on(Axis::Row).len(); - let cols = self.categories_on(Axis::Column).len(); - if rows == 0 { + let regular_rows: Vec = self + .categories_on(Axis::Row) + .into_iter() + .filter(|c| !c.starts_with('_')) + .map(String::from) + .collect(); + let regular_cols: Vec = self + .categories_on(Axis::Column) + .into_iter() + .filter(|c| !c.starts_with('_')) + .map(String::from) + .collect(); + if regular_rows.is_empty() { + // Bump any virtual on Row to None + let bump: Vec = self + .categories_on(Axis::Row) + .into_iter() + .filter(|c| c.starts_with('_')) + .map(String::from) + .collect(); + for c in bump { + self.category_axes.insert(c, Axis::None); + } Axis::Row - } else if cols == 0 { + } else if regular_cols.is_empty() { + let bump: Vec = self + .categories_on(Axis::Column) + .into_iter() + .filter(|c| c.starts_with('_')) + .map(String::from) + .collect(); + for c in bump { + self.category_axes.insert(c, Axis::None); + } Axis::Column } else { Axis::Page