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)
516 lines
17 KiB
Rust
516 lines
17 KiB
Rust
use indexmap::IndexMap;
|
|
use serde::{Deserialize, Serialize};
|
|
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,
|
|
/// Axis assignment for each category
|
|
pub category_axes: IndexMap<String, Axis>,
|
|
/// For page axis: selected item per category
|
|
pub page_selections: HashMap<String, String>,
|
|
/// Hidden items per category
|
|
pub hidden_items: HashMap<String, HashSet<String>>,
|
|
/// Collapsed groups per category
|
|
pub collapsed_groups: HashMap<String, HashSet<String>>,
|
|
/// 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,
|
|
/// Selected cell (row_idx, col_idx)
|
|
pub selected: (usize, usize),
|
|
}
|
|
|
|
impl View {
|
|
pub fn new(name: impl Into<String>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
category_axes: IndexMap::new(),
|
|
page_selections: HashMap::new(),
|
|
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),
|
|
}
|
|
}
|
|
|
|
pub fn on_category_added(&mut self, cat_name: &str) {
|
|
if !self.category_axes.contains_key(cat_name) {
|
|
// 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 regular_rows: Vec<String> = self
|
|
.categories_on(Axis::Row)
|
|
.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
|
|
} 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
|
|
} else {
|
|
Axis::Page
|
|
}
|
|
};
|
|
self.category_axes.insert(cat_name.to_string(), axis);
|
|
}
|
|
}
|
|
|
|
pub fn set_axis(&mut self, cat_name: &str, axis: Axis) {
|
|
if let Some(a) = self.category_axes.get_mut(cat_name) {
|
|
*a = axis;
|
|
}
|
|
}
|
|
|
|
pub fn axis_of(&self, cat_name: &str) -> Axis {
|
|
*self
|
|
.category_axes
|
|
.get(cat_name)
|
|
.expect("axis_of called for category not registered with this view")
|
|
}
|
|
|
|
pub fn categories_on(&self, axis: Axis) -> Vec<&str> {
|
|
self.category_axes
|
|
.iter()
|
|
.filter(|(_, &a)| a == axis)
|
|
.map(|(n, _)| n.as_str())
|
|
.collect()
|
|
}
|
|
|
|
pub fn set_page_selection(&mut self, cat_name: &str, item: &str) {
|
|
self.page_selections
|
|
.insert(cat_name.to_string(), item.to_string());
|
|
}
|
|
|
|
pub fn page_selection(&self, cat_name: &str) -> Option<&str> {
|
|
self.page_selections.get(cat_name).map(|s| s.as_str())
|
|
}
|
|
|
|
pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) {
|
|
let set = self
|
|
.collapsed_groups
|
|
.entry(cat_name.to_string())
|
|
.or_default();
|
|
if set.contains(group_name) {
|
|
set.remove(group_name);
|
|
} else {
|
|
set.insert(group_name.to_string());
|
|
}
|
|
}
|
|
|
|
pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool {
|
|
self.collapsed_groups
|
|
.get(cat_name)
|
|
.map(|s| s.contains(group_name))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub fn hide_item(&mut self, cat_name: &str, item_name: &str) {
|
|
self.hidden_items
|
|
.entry(cat_name.to_string())
|
|
.or_default()
|
|
.insert(item_name.to_string());
|
|
}
|
|
|
|
pub fn show_item(&mut self, cat_name: &str, item_name: &str) {
|
|
if let Some(set) = self.hidden_items.get_mut(cat_name) {
|
|
set.remove(item_name);
|
|
}
|
|
}
|
|
|
|
pub fn is_hidden(&self, cat_name: &str, item_name: &str) -> bool {
|
|
self.hidden_items
|
|
.get(cat_name)
|
|
.map(|s| s.contains(item_name))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Swap all Row categories to Column and all Column categories to Row.
|
|
/// Page categories are unaffected.
|
|
pub fn transpose_axes(&mut self) {
|
|
let rows: Vec<String> = self
|
|
.categories_on(Axis::Row)
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
let cols: Vec<String> = self
|
|
.categories_on(Axis::Column)
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
for cat in &rows {
|
|
self.set_axis(cat, Axis::Column);
|
|
}
|
|
for cat in &cols {
|
|
self.set_axis(cat, Axis::Row);
|
|
}
|
|
self.selected = (0, 0);
|
|
self.row_offset = 0;
|
|
self.col_offset = 0;
|
|
}
|
|
|
|
/// Cycle axis for a category: Row → Column → Page → None → Row
|
|
pub fn cycle_axis(&mut self, cat_name: &str) {
|
|
let next = match self.axis_of(cat_name) {
|
|
Axis::Row => Axis::Column,
|
|
Axis::Column => Axis::Page,
|
|
Axis::Page => Axis::None,
|
|
Axis::None => Axis::Row,
|
|
};
|
|
self.set_axis(cat_name, next);
|
|
self.selected = (0, 0);
|
|
self.row_offset = 0;
|
|
self.col_offset = 0;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::View;
|
|
use crate::view::Axis;
|
|
|
|
fn view_with_cats(cats: &[&str]) -> View {
|
|
let mut v = View::new("Test");
|
|
for &c in cats {
|
|
v.on_category_added(c);
|
|
}
|
|
v
|
|
}
|
|
|
|
#[test]
|
|
fn first_category_assigned_to_row() {
|
|
let v = view_with_cats(&["Region"]);
|
|
assert_eq!(v.axis_of("Region"), Axis::Row);
|
|
}
|
|
|
|
#[test]
|
|
fn second_category_assigned_to_column() {
|
|
let v = view_with_cats(&["Region", "Product"]);
|
|
assert_eq!(v.axis_of("Product"), Axis::Column);
|
|
}
|
|
|
|
#[test]
|
|
fn third_and_later_categories_assigned_to_page() {
|
|
let v = view_with_cats(&["Region", "Product", "Time", "Scenario"]);
|
|
assert_eq!(v.axis_of("Time"), Axis::Page);
|
|
assert_eq!(v.axis_of("Scenario"), Axis::Page);
|
|
}
|
|
|
|
#[test]
|
|
fn set_axis_changes_assignment() {
|
|
let mut v = view_with_cats(&["Region", "Product"]);
|
|
v.set_axis("Region", Axis::Column);
|
|
assert_eq!(v.axis_of("Region"), Axis::Column);
|
|
}
|
|
|
|
#[test]
|
|
fn categories_on_returns_correct_list() {
|
|
let v = view_with_cats(&["Region", "Product", "Time"]);
|
|
assert_eq!(v.categories_on(Axis::Row), vec!["Region"]);
|
|
assert_eq!(v.categories_on(Axis::Column), vec!["Product"]);
|
|
assert_eq!(v.categories_on(Axis::Page), vec!["Time"]);
|
|
}
|
|
|
|
#[test]
|
|
fn transpose_axes_swaps_row_and_column() {
|
|
let mut v = view_with_cats(&["Region", "Product"]);
|
|
// Default: Region=Row, Product=Column
|
|
assert_eq!(v.axis_of("Region"), Axis::Row);
|
|
assert_eq!(v.axis_of("Product"), Axis::Column);
|
|
v.transpose_axes();
|
|
assert_eq!(v.axis_of("Region"), Axis::Column);
|
|
assert_eq!(v.axis_of("Product"), Axis::Row);
|
|
}
|
|
|
|
#[test]
|
|
fn transpose_axes_leaves_page_categories_unchanged() {
|
|
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
|
// Default: Region=Row, Product=Column, Time=Page
|
|
v.transpose_axes();
|
|
assert_eq!(v.axis_of("Time"), Axis::Page);
|
|
}
|
|
|
|
#[test]
|
|
fn transpose_axes_is_its_own_inverse() {
|
|
let mut v = view_with_cats(&["Region", "Product"]);
|
|
v.transpose_axes();
|
|
v.transpose_axes();
|
|
assert_eq!(v.axis_of("Region"), Axis::Row);
|
|
assert_eq!(v.axis_of("Product"), Axis::Column);
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "axis_of called for category not registered")]
|
|
fn axis_of_unknown_category_panics() {
|
|
let v = View::new("Test");
|
|
v.axis_of("Ghost");
|
|
}
|
|
|
|
#[test]
|
|
fn page_selection_set_and_get() {
|
|
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
|
v.set_page_selection("Time", "Q1");
|
|
assert_eq!(v.page_selection("Time"), Some("Q1"));
|
|
assert_eq!(v.page_selection("Region"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_group_collapse_toggles_twice() {
|
|
let mut v = View::new("Test");
|
|
assert!(!v.is_group_collapsed("Time", "Q1"));
|
|
v.toggle_group_collapse("Time", "Q1");
|
|
assert!(v.is_group_collapsed("Time", "Q1"));
|
|
v.toggle_group_collapse("Time", "Q1");
|
|
assert!(!v.is_group_collapsed("Time", "Q1"));
|
|
}
|
|
|
|
#[test]
|
|
fn is_group_collapsed_isolated_across_categories() {
|
|
let mut v = View::new("Test");
|
|
v.toggle_group_collapse("Cat1", "G1");
|
|
assert!(!v.is_group_collapsed("Cat2", "G1"));
|
|
}
|
|
|
|
#[test]
|
|
fn is_group_collapsed_isolated_across_groups() {
|
|
let mut v = View::new("Test");
|
|
v.toggle_group_collapse("Cat1", "G1");
|
|
assert!(!v.is_group_collapsed("Cat1", "G2"));
|
|
}
|
|
|
|
#[test]
|
|
fn hide_and_show_item() {
|
|
let mut v = View::new("Test");
|
|
assert!(!v.is_hidden("Region", "East"));
|
|
v.hide_item("Region", "East");
|
|
assert!(v.is_hidden("Region", "East"));
|
|
v.show_item("Region", "East");
|
|
assert!(!v.is_hidden("Region", "East"));
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_axis_row_to_column() {
|
|
let mut v = view_with_cats(&["Region", "Product"]);
|
|
v.cycle_axis("Region");
|
|
assert_eq!(v.axis_of("Region"), Axis::Column);
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_axis_column_to_page() {
|
|
let mut v = view_with_cats(&["Region", "Product"]);
|
|
v.set_axis("Product", Axis::Column);
|
|
v.cycle_axis("Product");
|
|
assert_eq!(v.axis_of("Product"), Axis::Page);
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_axis_page_to_none() {
|
|
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
|
v.cycle_axis("Time");
|
|
assert_eq!(v.axis_of("Time"), Axis::None);
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_axis_none_to_row() {
|
|
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
|
v.set_axis("Time", Axis::None);
|
|
v.cycle_axis("Time");
|
|
assert_eq!(v.axis_of("Time"), Axis::Row);
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_axis_resets_scroll_and_selection() {
|
|
let mut v = view_with_cats(&["Region"]);
|
|
v.row_offset = 5;
|
|
v.col_offset = 3;
|
|
v.selected = (2, 2);
|
|
v.cycle_axis("Region");
|
|
assert_eq!(v.row_offset, 0);
|
|
assert_eq!(v.col_offset, 0);
|
|
assert_eq!(v.selected, (0, 0));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod prop_tests {
|
|
use super::View;
|
|
use crate::view::Axis;
|
|
use proptest::prelude::*;
|
|
|
|
fn unique_cat_names() -> impl Strategy<Value = Vec<String>> {
|
|
prop::collection::hash_set("[A-Za-z][a-z]{1,7}", 1usize..=8)
|
|
.prop_map(|s| s.into_iter().collect::<Vec<_>>())
|
|
}
|
|
|
|
proptest! {
|
|
/// axis_of and categories_on are consistent: cat is in categories_on(axis_of(cat))
|
|
#[test]
|
|
fn axis_of_and_categories_on_consistent(cats in unique_cat_names()) {
|
|
let mut v = View::new("T");
|
|
for c in &cats { v.on_category_added(c); }
|
|
for c in &cats {
|
|
let axis = v.axis_of(c);
|
|
let on_axis = v.categories_on(axis);
|
|
prop_assert!(on_axis.contains(&c.as_str()),
|
|
"categories_on({:?}) should contain '{}'", axis, c);
|
|
}
|
|
}
|
|
|
|
/// Each known category appears on exactly one axis
|
|
#[test]
|
|
fn each_category_on_exactly_one_axis(cats in unique_cat_names()) {
|
|
let mut v = View::new("T");
|
|
for c in &cats { v.on_category_added(c); }
|
|
let all_axes = [Axis::Row, Axis::Column, Axis::Page, Axis::None];
|
|
for c in &cats {
|
|
let count = all_axes.iter()
|
|
.filter(|&&ax| v.categories_on(ax).contains(&c.as_str()))
|
|
.count();
|
|
prop_assert_eq!(count, 1,
|
|
"category '{}' should be on exactly one axis, found {}", c, count);
|
|
}
|
|
}
|
|
|
|
/// on_category_added is idempotent: adding same cat twice keeps original axis
|
|
#[test]
|
|
fn on_category_added_idempotent(cats in unique_cat_names()) {
|
|
let mut v = View::new("T");
|
|
for c in &cats { v.on_category_added(c); }
|
|
let axes_before: Vec<Axis> = cats.iter().map(|c| v.axis_of(c)).collect();
|
|
for c in &cats { v.on_category_added(c); }
|
|
let axes_after: Vec<Axis> = cats.iter().map(|c| v.axis_of(c)).collect();
|
|
prop_assert_eq!(axes_before, axes_after);
|
|
}
|
|
|
|
/// set_axis updates axis_of for the target category
|
|
#[test]
|
|
fn set_axis_updates_axis_of(
|
|
cats in unique_cat_names(),
|
|
target_idx in 0usize..8,
|
|
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
|
|
) {
|
|
let mut v = View::new("T");
|
|
for c in &cats { v.on_category_added(c); }
|
|
let idx = target_idx % cats.len();
|
|
let cat = &cats[idx];
|
|
v.set_axis(cat, axis);
|
|
prop_assert_eq!(v.axis_of(cat), axis);
|
|
}
|
|
|
|
/// After set_axis(cat, X), cat is NOT in categories_on(Y) for Y ≠ X
|
|
#[test]
|
|
fn set_axis_exclusive(
|
|
cats in unique_cat_names(),
|
|
target_idx in 0usize..8,
|
|
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
|
|
) {
|
|
let mut v = View::new("T");
|
|
for c in &cats { v.on_category_added(c); }
|
|
let idx = target_idx % cats.len();
|
|
let cat = &cats[idx];
|
|
v.set_axis(cat, axis);
|
|
let other_axes = [Axis::Row, Axis::Column, Axis::Page]
|
|
.into_iter()
|
|
.filter(|&a| a != axis);
|
|
for other in other_axes {
|
|
prop_assert!(!v.categories_on(other).contains(&cat.as_str()),
|
|
"after set_axis({:?}), '{}' should not be in categories_on({:?})",
|
|
axis, cat, other);
|
|
}
|
|
}
|
|
|
|
/// No two categories share the same axis entry (map guarantees uniqueness by key)
|
|
/// — equivalently, total count across all axes equals number of known categories
|
|
#[test]
|
|
fn total_category_count_consistent(cats in unique_cat_names()) {
|
|
let mut v = View::new("T");
|
|
for c in &cats { v.on_category_added(c); }
|
|
let total: usize = [Axis::Row, Axis::Column, Axis::Page]
|
|
.iter()
|
|
.map(|&ax| v.categories_on(ax).len())
|
|
.sum();
|
|
prop_assert_eq!(total, cats.len());
|
|
}
|
|
|
|
/// page_selection round-trips: set then get returns the same value
|
|
#[test]
|
|
fn page_selection_roundtrip(
|
|
cat in "[A-Za-z][a-z]{1,7}",
|
|
item in "[A-Za-z][a-z]{1,7}",
|
|
) {
|
|
let mut v = View::new("T");
|
|
v.set_page_selection(&cat, &item);
|
|
prop_assert_eq!(v.page_selection(&cat), Some(item.as_str()));
|
|
}
|
|
|
|
/// hide/show round-trip: hiding then showing leaves item visible
|
|
#[test]
|
|
fn hide_show_roundtrip(
|
|
cat in "[A-Za-z][a-z]{1,7}",
|
|
item in "[A-Za-z][a-z]{1,7}",
|
|
) {
|
|
let mut v = View::new("T");
|
|
v.hide_item(&cat, &item);
|
|
prop_assert!(v.is_hidden(&cat, &item));
|
|
v.show_item(&cat, &item);
|
|
prop_assert!(!v.is_hidden(&cat, &item));
|
|
}
|
|
|
|
/// toggle_group_collapse is its own inverse
|
|
#[test]
|
|
fn toggle_group_collapse_involutive(
|
|
cat in "[A-Za-z][a-z]{1,7}",
|
|
group in "[A-Za-z][a-z]{1,7}",
|
|
) {
|
|
let mut v = View::new("T");
|
|
let initial = v.is_group_collapsed(&cat, &group);
|
|
v.toggle_group_collapse(&cat, &group);
|
|
v.toggle_group_collapse(&cat, &group);
|
|
prop_assert_eq!(v.is_group_collapsed(&cat, &group), initial);
|
|
}
|
|
}
|
|
}
|