feat: add prune empty feature for pivot views

Add prune_empty feature to hide empty rows/columns in pivot mode.

View gains prune_empty boolean (default false for backward compat).
GridLayout::prune_empty() removes data rows where all columns are
empty and data columns where all rows are empty.

Group headers are preserved if at least one data item survives.
In records mode, pruning is skipped (user drilled in to see all data).

EditOrDrill command updated to check for regular (non-virtual)
categories when determining if a cell is aggregated.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
This commit is contained in:
Edward Langley
2026-04-06 15:09:57 -07:00
parent 55cad99ae1
commit eb83df9984
3 changed files with 185 additions and 7 deletions

View File

@ -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<String> = model
.category_names()
.into_iter()
.filter(|c| !c.starts_with('_'))
.map(String::from)
.collect();
let mut col_items: Vec<AxisEntry> = 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<bool> = (0..rc)
.map(|ri| (0..cc).any(|ci| has_value[ri][ci]))
.collect();
// Which data-col indices are non-empty?
let keep_col: Vec<bool> = (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<AxisEntry> = 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<AxisEntry> = 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<AxisEntry> = (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();