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

@ -672,7 +672,16 @@ impl Cmd for EditOrDrill {
"edit-or-drill" "edit-or-drill"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
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 { if is_aggregated {
let Some(key) = ctx.cell_key.clone() else { let Some(key) = ctx.cell_key.clone() else {
return vec![effect::set_status( return vec![effect::set_status(

View File

@ -53,6 +53,9 @@ impl GridLayout {
layout.records = Some(records); layout.records = Some(records);
} }
} }
if view.prune_empty {
layout.prune_empty(model);
}
layout layout
} }
@ -152,10 +155,11 @@ impl GridLayout {
.map(|i| AxisEntry::DataItem(vec![i.to_string()])) .map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect(); .collect();
// Synthesize col items: one per category + "Value" // Synthesize col items: one per non-virtual category + "Value"
let cat_names: Vec<String> = model let cat_names: Vec<String> = model
.category_names() .category_names()
.into_iter() .into_iter()
.filter(|c| !c.starts_with('_'))
.map(String::from) .map(String::from)
.collect(); .collect();
let mut col_items: Vec<AxisEntry> = cat_names 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. /// Whether this layout is in records mode.
pub fn is_records_mode(&self) -> bool { pub fn is_records_mode(&self) -> bool {
self.records.is_some() self.records.is_some()
@ -450,6 +556,30 @@ mod tests {
m 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] #[test]
fn records_mode_activated_when_index_and_dim_on_axes() { fn records_mode_activated_when_index_and_dim_on_axes() {
let mut m = records_model(); let mut m = records_model();

View File

@ -4,6 +4,10 @@ use std::collections::{HashMap, HashSet};
use super::axis::Axis; use super::axis::Axis;
fn default_prune() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct View { pub struct View {
pub name: String, pub name: String,
@ -17,6 +21,9 @@ pub struct View {
pub collapsed_groups: HashMap<String, HashSet<String>>, pub collapsed_groups: HashMap<String, HashSet<String>>,
/// Number format string (e.g. ",.0f" for comma-separated integer) /// Number format string (e.g. ",.0f" for comma-separated integer)
pub number_format: String, 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 /// Scroll offset for grid
pub row_offset: usize, pub row_offset: usize,
pub col_offset: usize, pub col_offset: usize,
@ -33,6 +40,7 @@ impl View {
hidden_items: HashMap::new(), hidden_items: HashMap::new(),
collapsed_groups: HashMap::new(), collapsed_groups: HashMap::new(),
number_format: ",.0".to_string(), number_format: ",.0".to_string(),
prune_empty: false,
row_offset: 0, row_offset: 0,
col_offset: 0, col_offset: 0,
selected: (0, 0), selected: (0, 0),
@ -41,16 +49,47 @@ impl View {
pub fn on_category_added(&mut self, cat_name: &str) { pub fn on_category_added(&mut self, cat_name: &str) {
if !self.category_axes.contains_key(cat_name) { 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. // 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('_') { let axis = if cat_name.starts_with('_') {
Axis::None Axis::None
} else { } else {
let rows = self.categories_on(Axis::Row).len(); let regular_rows: Vec<String> = self
let cols = self.categories_on(Axis::Column).len(); .categories_on(Axis::Row)
if rows == 0 { .into_iter()
.filter(|c| !c.starts_with('_'))
.map(String::from)
.collect();
let regular_cols: Vec<String> = 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<String> = 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 Axis::Row
} else if cols == 0 { } else if regular_cols.is_empty() {
let bump: Vec<String> = 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 Axis::Column
} else { } else {
Axis::Page Axis::Page